diff --git a/.ddev/.env.playwright b/.ddev/.env.playwright new file mode 100644 index 0000000..72ff1a8 --- /dev/null +++ b/.ddev/.env.playwright @@ -0,0 +1 @@ +PLAYWRIGHT_DOCKER_IMAGE="mcr.microsoft.com/playwright:v1.64.1-noble" diff --git a/.ddev/.env.web b/.ddev/.env.web index 2d858f6..2d2b404 100644 --- a/.ddev/.env.web +++ b/.ddev/.env.web @@ -1 +1 @@ -DRUPAL_CORE="^10.5.10" +DRUPAL_CORE="^11.4.10" diff --git a/.ddev/addon-metadata/ddev-drupal-contrib/manifest.yaml b/.ddev/addon-metadata/ddev-drupal-contrib/manifest.yaml index 829cf56..e0ded89 100644 --- a/.ddev/addon-metadata/ddev-drupal-contrib/manifest.yaml +++ b/.ddev/addon-metadata/ddev-drupal-contrib/manifest.yaml @@ -1,7 +1,7 @@ name: ddev-drupal-contrib repository: ddev/ddev-drupal-contrib -version: 1.2.0 -install_date: "2026-09-17T22:53:45+02:00" +version: 1.2.2 +install_date: "2026-10-26T17:19:56+01:00" project_files: - commands/host/core-version - commands/web/eslint diff --git a/.ddev/addon-metadata/playwright/manifest.yaml b/.ddev/addon-metadata/playwright/manifest.yaml new file mode 100644 index 0000000..5448109 --- /dev/null +++ b/.ddev/addon-metadata/playwright/manifest.yaml @@ -0,0 +1,15 @@ +name: playwright +repository: julienloizelet/ddev-playwright +version: v3.1.0 +install_date: "2026-10-25T09:07:45+01:00" +project_files: + - commands/playwright/playwright + - commands/playwright/playwright-install + - commands/playwright/playwright-init + - playwright-build/Dockerfile + - playwright-build/kasmvnc.yaml + - playwright-build/xstartup + - playwright-build/entrypoint.sh + - docker-compose.playwright.yaml +global_files: [] +removal_actions: [] diff --git a/.ddev/commands/playwright/playwright b/.ddev/commands/playwright/playwright new file mode 100755 index 0000000..a326c0b --- /dev/null +++ b/.ddev/commands/playwright/playwright @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# +## Description: Run playwright command +## Usage: playwright [options] [command] +## Example: "ddev playwright test" +cd /var/www/html || exit 1 +cd "${PLAYWRIGHT_TEST_DIR}" || exit 1 + +export PLAYWRIGHT_BROWSERS_PATH=0 + +if [ -f "package-lock.json" ]; then + npx playwright "$@" +elif [ -f "yarn.lock" ]; then + yarn playwright "$@" +else + echo "ERROR: could not find lock file for a supported package manager (npm, yarn) in '${PLAYWRIGHT_TEST_DIR}'" + echo "Please run 'ddev playwright-install' or 'ddev playwright-init' to install playwright." + exit 1 +fi diff --git a/.ddev/commands/playwright/playwright-init b/.ddev/commands/playwright/playwright-init new file mode 100755 index 0000000..81d7385 --- /dev/null +++ b/.ddev/commands/playwright/playwright-init @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# +## Description: Init playwright in PLAYWRIGHT_TEST_DIR +## Usage: playwright-init --pm [npm|yarn] +## Example: "ddev playwright-init --pm yarn" + +function display_help() { + echo "Usage: $0 --pm [yarn|npm]" + echo "Example: $0 --pm yarn" + exit 1 +} + +# Default package manager is not set +PACKAGE_MANAGER="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --pm) PACKAGE_MANAGER="$2"; shift ;; + *) echo "Unknown parameter passed: $1"; display_help ;; + esac + shift +done + +# Check if package manager is specified and valid +if [[ -z "$PACKAGE_MANAGER" ]] || ! { [[ "$PACKAGE_MANAGER" == "yarn" ]] || [[ "$PACKAGE_MANAGER" == "npm" ]]; }; then + echo "Warning: Package manager should be specified with --pm and be one of 'yarn' or 'npm'." + echo "Defaulting to yarn" + PACKAGE_MANAGER="yarn" +fi + +cd /var/www/html || exit 1 +cd "${PLAYWRIGHT_TEST_DIR}" || exit 1 + +export PLAYWRIGHT_BROWSERS_PATH=0 + +if [[ $PACKAGE_MANAGER == "npm" ]]; then + npm init playwright@latest +elif [[ $PACKAGE_MANAGER == "yarn" ]]; then + yarn create playwright +fi + +# Conditionally copy an .env file if an example file exists +[ -f .env.example ] && [ ! -f .env ] && cp -n .env.example .env; exit 0 diff --git a/.ddev/commands/playwright/playwright-install b/.ddev/commands/playwright/playwright-install new file mode 100755 index 0000000..8e5ec82 --- /dev/null +++ b/.ddev/commands/playwright/playwright-install @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# +## Description: Install playwright in PLAYWRIGHT_TEST_DIR +## Usage: playwright-install --pm [npm|yarn] +## Example: "ddev playwright-install --pm npm" + +function display_help() { + echo "Usage: $0 --pm [yarn|npm]" + echo "Example: $0 --pm yarn" + exit 1 +} + +# Default package manager is not set +PACKAGE_MANAGER="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --pm) PACKAGE_MANAGER="$2"; shift ;; + *) echo "Unknown parameter passed: $1"; display_help ;; + esac + shift +done + +# Check if package manager is specified and valid +if [[ -z "$PACKAGE_MANAGER" ]] || ! { [[ "$PACKAGE_MANAGER" == "yarn" ]] || [[ "$PACKAGE_MANAGER" == "npm" ]]; }; then + echo "Warning: Package manager should be specified with --pm and be one of 'yarn' or 'npm'." + echo "Defaulting to yarn" + PACKAGE_MANAGER="yarn" +fi + +cd /var/www/html || exit 1 +cd "${PLAYWRIGHT_TEST_DIR}" || exit 1 + +export PLAYWRIGHT_BROWSERS_PATH=0 + +if [[ $PACKAGE_MANAGER == "npm" ]]; then + npm install + npx playwright install --with-deps +elif [[ $PACKAGE_MANAGER == "yarn" ]]; then + yarn install + yarn playwright install --with-deps +fi + +# Conditionally copy an .env file if an example file exists +[ -f .env.example ] && [ ! -f .env ] && cp -n .env.example .env; exit 0 diff --git a/.ddev/commands/web/eslint b/.ddev/commands/web/eslint new file mode 100755 index 0000000..6be295c --- /dev/null +++ b/.ddev/commands/web/eslint @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run eslint inside the web container +## Usage: eslint [flags] [args] +## Example: "ddev eslint" +## ExecRaw: true + +set -eu -o pipefail + +if "$DDEV_DOCROOT/core/node_modules/.bin/eslint" --version >/dev/null ; then + # Configure prettier + test -e .prettierrc.json || ln -s $DDEV_DOCROOT/core/.prettierrc.json . + test -e .prettierignore || echo '*.yml' > .prettierignore + # Change directory to the project root folder + cd "$DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH/${DDEV_SITENAME//-/_}" || exit + "$DDEV_COMPOSER_ROOT/$DDEV_DOCROOT/core/node_modules/.bin/eslint" --config="../../../core/.eslintrc.passing.json" --no-error-on-unmatched-pattern --ignore-pattern="*.es6.js" --resolve-plugins-relative-to=$DDEV_COMPOSER_ROOT/$DDEV_DOCROOT/core --ext=.js,.yml . "$@" +else + echo "eslint is not available. You may need to 'ddev exec \"cd $DDEV_DOCROOT/core && yarn install\"'" + exit 1 +fi diff --git a/.ddev/commands/web/expand-composer-json b/.ddev/commands/web/expand-composer-json new file mode 100755 index 0000000..393cc07 --- /dev/null +++ b/.ddev/commands/web/expand-composer-json @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Add Drupal core and other needed dependencies. +## Usage: expand-composer-json [flags] [PROJECT_NAME] +## Example: "ddev expand-composer-json ctools" +## ExecRaw: true +## MutagenSync: true + +set -eu -o pipefail + +# Set the default core version. +export DRUPAL_CORE=${DRUPAL_CORE:-^11} + +export _WEB_ROOT=$DDEV_DOCROOT +cd "$DDEV_COMPOSER_ROOT" || exit +curl -fsSL -O https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/scripts/expand_composer_json.php +_ddev_drupal_contrib_empty_composer=false +if [[ ! -f composer.json ]]; then + echo "{}" > composer.json + _ddev_drupal_contrib_empty_composer=true +fi +php expand_composer_json.php "$DDEV_SITENAME" +rm -f expand_composer_json.php +if [ "$_ddev_drupal_contrib_empty_composer" = true ]; then + rm -f composer.json +fi diff --git a/.ddev/commands/web/nightwatch b/.ddev/commands/web/nightwatch new file mode 100755 index 0000000..06b2ea2 --- /dev/null +++ b/.ddev/commands/web/nightwatch @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run nightwatch inside the web container +## Usage: nightwatch [flags] [args] +## Example: "ddev nightwatch" +## ExecRaw: true + +set -eu -o pipefail + +yarn --cwd "$DDEV_DOCROOT/core" test:nightwatch "$DDEV_APPROOT/$DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH/" "$@" diff --git a/.ddev/commands/web/phpcbf b/.ddev/commands/web/phpcbf new file mode 100755 index 0000000..a42a5b9 --- /dev/null +++ b/.ddev/commands/web/phpcbf @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run phpcbf inside the web container +## Usage: phpcbf [flags] [args] +## Example: "ddev phpcbf" or "ddev phpcbf -n" +## ExecRaw: true + +set -eu -o pipefail + +if ! command -v phpcbf >/dev/null; then + echo "phpcbf is not available. You may need to 'ddev composer install'" + exit 1 +fi +test -e phpcs.xml.dist || curl -fsSL -O https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/assets/phpcs.xml.dist +phpcbf -s --report-full --report-summary --report-source $DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH --ignore=*/.ddev/* "$@" diff --git a/.ddev/commands/web/phpcs b/.ddev/commands/web/phpcs new file mode 100755 index 0000000..b4d19af --- /dev/null +++ b/.ddev/commands/web/phpcs @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run phpcs inside the web container +## Usage: phpcs [flags] [args] +## Example: "ddev phpcs" or "ddev phpcs -n" +## ExecRaw: true + +set -eu -o pipefail + +if ! command -v phpcs >/dev/null; then + echo "phpcs is not available. You may need to 'ddev composer install'" + exit 1 +fi +test -e phpcs.xml.dist || curl -fsSL -O https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/assets/phpcs.xml.dist +phpcs -s --report-full --report-summary --report-source $DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH --ignore=*/.ddev/* "$@" diff --git a/.ddev/commands/web/phpstan b/.ddev/commands/web/phpstan new file mode 100755 index 0000000..1619b93 --- /dev/null +++ b/.ddev/commands/web/phpstan @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run phpstan inside the web container +## Usage: phpstan [flags] [args] +## Example: "ddev phpstan" or "ddev phpstan -n" +## ExecRaw: true +## MutagenSync: true + +set -eu -o pipefail + +if ! command -v phpstan >/dev/null; then + echo "phpstan is not available. You may need to 'ddev poser'" + exit 1 +fi +# PHPStan discovers phpstan.neon.dist and phpstan.dist.neon on its own, so only scaffold when neither exists. +if [ -e phpstan.neon ] || { [ ! -e phpstan.neon.dist ] && [ ! -e phpstan.dist.neon ]; }; then + test -e phpstan.neon || curl -fsSL -O https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/assets/phpstan.neon + # See https://git.drupalcode.org/project/gitlab_templates/-/commit/a107b7f1f79af12e0b09f70be47b68e3f69b4504 + sed -i 's/BASELINE_PLACEHOLDER/phpstan-baseline.neon/g' phpstan.neon + # Add an empty baseline file to ensure it exists. + test -e phpstan-baseline.neon || touch phpstan-baseline.neon +fi + +EXTENSION_DIRECTORY=$DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH/${DDEV_SITENAME//-/_} +cd "$EXTENSION_DIRECTORY" || exit 1 +# Ensure PHPStan configuration is symlinked from project root. +ln -s $DDEV_DOCROOT/phpstan.neon 2>/dev/null || true +ln -s $DDEV_DOCROOT/phpstan-baseline.neon 2>/dev/null || true +phpstan analyze . "$@" diff --git a/.ddev/commands/web/phpunit b/.ddev/commands/web/phpunit new file mode 100755 index 0000000..b967304 --- /dev/null +++ b/.ddev/commands/web/phpunit @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run phpunit inside the web container +## Usage: phpunit [flags] [args] +## Example: "ddev phpunit" or "ddev phpunit --stop-on-failure" +## Example: "ddev phpunit web/modules/custom/mymodule/tests/src/Unit/MyTest.php" +## Example: "ddev phpunit --filter testMyFunction" +## ExecRaw: true + +set -eu -o pipefail + +if ! command -v phpunit >/dev/null; then + echo "phpunit is not available. You may need to 'ddev composer install'" + exit 1 +fi + +# CHECK for local config. +if [ -f "phpunit.xml" ]; then + # Defer to local config + phpunit "$@" +else + # Check if we are running a specific test or test folder. + if [ $# -gt 0 ] && [ -e "$1" ]; then + TEST_PATH="$1" + shift + phpunit --bootstrap $PWD/$DDEV_DOCROOT/core/tests/bootstrap.php "$TEST_PATH" "$@" + else + # Run all custom module tests. + phpunit --bootstrap $PWD/$DDEV_DOCROOT/core/tests/bootstrap.php $DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH "$@" + fi +fi diff --git a/.ddev/commands/web/poser b/.ddev/commands/web/poser new file mode 100755 index 0000000..b6c30bb --- /dev/null +++ b/.ddev/commands/web/poser @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Expand composer.json and run composer update. +## Usage: poser [flags] [args] +## Example: "ddev poser" or "ddev poser --prefer-source" or "ddev poser --prefer-lowest --prefer-stable" +## ExecRaw: true +## MutagenSync: true + +set -eu -o pipefail + +export COMPOSER=composer.contrib.json +.ddev/commands/web/expand-composer-json +composer update "$@" +# The -f flag suppresses errors if lock file does not exist. +rm -f composer.contrib.json composer.contrib.lock +touch $DDEV_DOCROOT/core/.env diff --git a/.ddev/commands/web/stylelint b/.ddev/commands/web/stylelint new file mode 100755 index 0000000..521d3bc --- /dev/null +++ b/.ddev/commands/web/stylelint @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Run stylelint inside the web container +## Usage: stylelint [flags] [args] +## Example: "ddev stylelint" +## ExecRaw: true + +set -eu -o pipefail + +if "$DDEV_DOCROOT/core/node_modules/.bin/stylelint" --version >/dev/null ; then + # Configure prettier + test -e .prettierrc.json || ln -s $DDEV_DOCROOT/core/.prettierrc.json . + # Change directory to the project root folder + cd "$DDEV_DOCROOT/$DRUPAL_PROJECTS_PATH/${DDEV_SITENAME//-/_}" || exit + "$DDEV_COMPOSER_ROOT/$DDEV_DOCROOT/core/node_modules/.bin/stylelint" --color --config "$DDEV_COMPOSER_ROOT/$DDEV_DOCROOT/core/.stylelintrc.json" "./**/*.css" "$@" +else + echo "stylelint is not available. You may need to 'ddev exec \"cd $DDEV_DOCROOT/core && yarn install\"'" + exit 1 +fi diff --git a/.ddev/commands/web/symlink-project b/.ddev/commands/web/symlink-project new file mode 100755 index 0000000..e69631e --- /dev/null +++ b/.ddev/commands/web/symlink-project @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +#ddev-generated +## Command provided by https://github.com/ddev/ddev-drupal-contrib +## Description: Symlink project files into the configured location (defaults to `web/modules/custom/[PROJECT_NAME]`) +## Usage: symlink-project [flags] [args] +## Example: "ddev symlink-project" +## ExecRaw: true + +set -eu -o pipefail + +export _WEB_ROOT=$DDEV_DOCROOT +cd "$DDEV_COMPOSER_ROOT" || exit +curl -fsSL -O https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/scripts/symlink_project.php + +if [ -z "${PROJECT_FILES-}" ]; then + # Suppress a warning. + PROJECT_FILES=$(ls -A --ignore="$DDEV_DOCROOT" --ignore="vendor" --ignore="symlink_project.php" --ignore=".ddev" --ignore=".idea" --ignore=".claude") + export PROJECT_FILES +fi + +# Symlink name using underscores. +# @see https://www.drupal.org/docs/develop/creating-modules/naming-and-placing-your-drupal-module +# DRUPAL_PROJECT_FOLDER is used by the symlink-project script in gitlab_templates +export DRUPAL_PROJECT_FOLDER=${DDEV_DOCROOT}/${DRUPAL_PROJECTS_PATH}/${DDEV_SITENAME//-/_} +php symlink_project.php +rm -f symlink_project.php + +# Ignore generated Drupal paths, unless the project already manages its own .gitignore. +if [ ! -e .gitignore ]; then + printf "/vendor/\n/$DDEV_DOCROOT/\n" > .gitignore +fi diff --git a/.ddev/config.yaml b/.ddev/config.yaml index ed88d87..3bb74fb 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -1,7 +1,7 @@ name: os2forms-f2 -type: drupal10 +type: drupal11 docroot: web -php_version: "8.3" +php_version: "8.4" webserver_type: nginx-fpm xdebug_enabled: false additional_hostnames: [] @@ -9,11 +9,17 @@ additional_fqdns: [] database: type: mariadb version: "11.8" +hooks: + post-start: + - exec: COMPOSER=composer.lenient.json composer init --no-interaction + - exec: COMPOSER=composer.lenient.json composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true + - exec: COMPOSER=composer.lenient.json composer require mglaman/composer-drupal-lenient:^1.0 + - exec: rm -f composer.lenient.* use_dns_when_possible: true composer_version: "2" web_environment: [] nodejs_version: "24" -corepack_enable: false +corepack_enable: true # Key features of DDEV's config.yaml: @@ -28,7 +34,7 @@ corepack_enable: false # docroot: # Relative path to the directory containing index.php. -# php_version: "8.4" # PHP version to use, "5.6" through "8.5" +# php_version: "8.4" # PHP version to use; see https://docs.ddev.com/en/stable/users/configuration/config/#php_version. # You can explicitly specify the webimage but this # is not recommended, as the images are often closely tied to DDEV's behavior, diff --git a/.ddev/docker-compose.override.yaml b/.ddev/docker-compose.override.yaml new file mode 100644 index 0000000..4b67e9c --- /dev/null +++ b/.ddev/docker-compose.override.yaml @@ -0,0 +1,4 @@ +services: + playwright: + volumes: + - ../docs/user-manual:/docs/user-manual diff --git a/.ddev/docker-compose.playwright.yaml b/.ddev/docker-compose.playwright.yaml new file mode 100644 index 0000000..88a57dd --- /dev/null +++ b/.ddev/docker-compose.playwright.yaml @@ -0,0 +1,44 @@ +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# +services: + playwright: + image: ${PLAYWRIGHT_DOCKER_IMAGE:-mcr.microsoft.com/playwright:v1.62.1-noble}-${DDEV_SITENAME}-built + build: + context: playwright-build + args: + PLAYWRIGHT_DOCKER_IMAGE: ${PLAYWRIGHT_DOCKER_IMAGE:-mcr.microsoft.com/playwright:v1.62.1-noble} + KASMVNC_VERSION: ${KASMVNC_VERSION:-1.4.0} + username: ${DDEV_USER} + uid: ${DDEV_UID} + gid: ${DDEV_GID} + container_name: ddev-${DDEV_SITENAME}-playwright + user: "${DDEV_UID}:${DDEV_GID}" + hostname: ${DDEV_SITENAME}-playwright + # These labels ensure this service is discoverable by ddev. + labels: + com.ddev.site-name: ${DDEV_SITENAME} + com.ddev.approot: ${DDEV_APPROOT} + environment: + # Modify the PLAYWRIGHT_TEST_DIR folder path to suit your needs + - PLAYWRIGHT_TEST_DIR=tests/Playwright + - NETWORK_IFACE=eth0 + - DISPLAY=:1 + - VIRTUAL_HOST=$DDEV_HOSTNAME + - HTTP_EXPOSE=8443:8444,9322:9323 + - HTTPS_EXPOSE=8444:8444,9323:9323 + expose: + - "8444" + - "9323" + depends_on: + - web + volumes: + - .:/mnt/ddev_config + - ddev-global-cache:/mnt/ddev-global-cache + - ../:/var/www/html:rw + working_dir: /var/www/html + x-ddev: + ssh-shell: bash diff --git a/.ddev/playwright-build/Dockerfile b/.ddev/playwright-build/Dockerfile new file mode 100644 index 0000000..bb9d0d9 --- /dev/null +++ b/.ddev/playwright-build/Dockerfile @@ -0,0 +1,96 @@ +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# +ARG PLAYWRIGHT_DOCKER_IMAGE=mcr.microsoft.com/playwright:v1.62.1-noble +ARG KASMVNC_VERSION=1.4.0 +FROM ${PLAYWRIGHT_DOCKER_IMAGE} + +ARG PLAYWRIGHT_DOCKER_IMAGE +ARG KASMVNC_VERSION +LABEL PARENT_IMAGE="${PLAYWRIGHT_DOCKER_IMAGE}" + +# Debian images by default disable apt caching, so turn it on until we finish +# the build. +RUN mv /etc/apt/apt.conf.d/docker-clean /etc/apt/docker-clean-disabled + +# Remove any existing pwuser/ubuntu users to avoid UID conflicts +RUN deluser --remove-home pwuser || true +RUN deluser --remove-home ubuntu || true +# Accept build arguments for user creation +ARG username +ARG uid +ARG gid +# Ensure tty group exists +RUN getent group tty || groupadd tty +# Create group and user, trying multiple methods for compatibility +RUN (groupadd --gid "$gid" "$username" || groupadd "$username" || true) && \ + (useradd -G tty -l -m -s "/bin/bash" --gid "$username" --comment '' --uid "$uid" "$username" || \ + useradd -G tty -l -m -s "/bin/bash" --gid "$username" --comment '' "$username" || \ + useradd -G tty -l -m -s "/bin/bash" --gid "$gid" --comment '' "$username" || \ + useradd -G tty -l -m -s "/bin/bash" --comment '' "$username") + +# Define CAROOT for mkcert +ENV CAROOT=/mnt/ddev-global-cache/mkcert + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y sudo + +# Allow the `$username` user passwordless sudo for `mkcert -install` +RUN mkdir -p /etc/sudoers.d && \ + echo "$username ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/$username" && \ + chmod 0440 "/etc/sudoers.d/$username" + +# Install mkcert for the correct architecture +ARG TARGETARCH +RUN mkdir -p /usr/local/bin && \ + curl --fail -JL -s -o /usr/local/bin/mkcert "https://dl.filippo.io/mkcert/latest?for=linux/${TARGETARCH}" && \ + chmod +x /usr/local/bin/mkcert + +# Install a window manager. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y icewm xauth + +# Enable universe repository for Ubuntu (needed for KasmVNC dependencies for some versions). +RUN apt-get update && apt-get install -y software-properties-common && \ + add-apt-repository -y universe && apt-get update + + +# Install KasmVNC for remote access. +RUN if echo "$PLAYWRIGHT_DOCKER_IMAGE" | grep -q "jammy"; then \ + KASMVNC_DIST="jammy"; \ + elif echo "$PLAYWRIGHT_DOCKER_IMAGE" | grep -q "noble"; then \ + KASMVNC_DIST="noble"; \ + else \ + KASMVNC_DIST="bullseye"; \ + fi && \ + wget https://github.com/kasmtech/KasmVNC/releases/download/v${KASMVNC_VERSION}/kasmvncserver_${KASMVNC_DIST}_${KASMVNC_VERSION}_${TARGETARCH}.deb && \ + apt-get install -y ./kasmvncserver*.deb && \ + rm -f kasmvncserver*.deb + +# We're done with apt so disable caching again for the final image. +RUN mv /etc/apt/docker-clean-disabled /etc/apt/apt.conf.d/docker-clean + +# Prepare KasmVNC +RUN mkdir -p "/home/$username/.vnc" +COPY kasmvnc.yaml xstartup /home/"$username"/.vnc/ +RUN touch "/home/$username/.vnc/.de-was-selected" +# We actually disable auth, but KASM complains without it +RUN sudo -u "$username" /bin/bash -c "echo -e \"secret\nsecret\n\" | kasmvncpasswd -wo -u \"$username\"" +RUN chown -R "$uid:$gid" "/home/$username" +RUN usermod -a -G ssl-cert "$username" + +# Create the X11 socket directory with the standard permissions. +# The container runs as an unprivileged user. Xvnc would still create this +# directory itself, but it prints a misleading +# "_XSERVTransmkdir: ERROR: euid != 0" message in its log every time it does. +RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix + +COPY entrypoint.sh /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/.ddev/playwright-build/entrypoint.sh b/.ddev/playwright-build/entrypoint.sh new file mode 100755 index 0000000..a367585 --- /dev/null +++ b/.ddev/playwright-build/entrypoint.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# + +# Install DDEV certificate +mkcert -install + +# Set up homeadditions if present +if [ -d /mnt/ddev_config/.homeadditions ]; then + cp -r /mnt/ddev_config/.homeadditions/. $HOME/ +fi + +# Remove stale X11 lock and socket files. +# When the container was not stopped cleanly (crash, kill, host reboot), DDEV +# restarts the same container and these files survive in /tmp. KasmVNC would +# then consider display :1 taken and start Xvnc on :2, while everything else +# expects DISPLAY=:1, so the window manager would fail and the container would +# exit. Nothing else is running at this point, so removing them is safe. +rm -f /tmp/.X*-lock /tmp/.X11-unix/X* + +# Stop Xvnc cleanly when the container is stopped, so that its lock, socket and +# pid files are removed. +stop_vnc() { + sudo -u "$(whoami)" vncserver -kill :1 + exit 0 +} +trap stop_vnc TERM INT + +# Start KasmVNC server on display :1. +# Xvnc and the applications of xstartup are started in the background, then +# vncserver returns. Requesting :1 explicitly makes vncserver fail if that +# display is not available, instead of silently using another one. +# sudo does not change the user here, but it re-initializes the supplementary +# groups (ssl-cert, tty) that Docker drops when the container is started with +# "user: uid:gid". vncserver refuses to start when it cannot read the KasmVNC +# certificate key, which belongs to the ssl-cert group. +sudo -u "$(whoami)" vncserver :1 -disableBasicAuth || exit 1 + +# Keep the container alive as long as Xvnc runs. +# The lifetime of the container is bound to Xvnc, not to the window manager +# started by xstartup: if the window manager fails, headless tests keep working. +XVNC_PID=$(cat "$HOME/.vnc/$(uname -n):1.pid") +while kill -0 "$XVNC_PID" 2>/dev/null; do + sleep 1 +done +echo "Xvnc (PID $XVNC_PID) exited, stopping the container" >&2 +exit 1 diff --git a/.ddev/playwright-build/kasmvnc.yaml b/.ddev/playwright-build/kasmvnc.yaml new file mode 100644 index 0000000..daf83dc --- /dev/null +++ b/.ddev/playwright-build/kasmvnc.yaml @@ -0,0 +1,24 @@ +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# +logging: + log_writer_name: all + log_dest: syslog + level: 100 + +network: + ssl: + require_ssl: false + udp: + # Skip the public IP lookup that Xvnc performs when it starts. + # Without a fixed value, KasmVNC sends a STUN request to each of seven + # hardcoded internet servers and waits up to 10 seconds for each answer. + # On hosts where outgoing UDP is filtered, Xvnc blocks for about + # 70 seconds, X clients cannot connect in the meantime, and Xvnc then + # exits, which stops the container. + # See https://github.com/julienloizelet/ddev-playwright/issues/38 + # The UDP transport is not reachable through the DDEV router anyway. + public_ip: 127.0.0.1 diff --git a/.ddev/playwright-build/xstartup b/.ddev/playwright-build/xstartup new file mode 100755 index 0000000..07f26fb --- /dev/null +++ b/.ddev/playwright-build/xstartup @@ -0,0 +1,32 @@ +#!/bin/sh +#ddev-generated +# Remove the line above if you don't want this file to be overwritten when you run +# ddev add-on get julienloizelet/ddev-playwright +# +# This file comes from https://github.com/julienloizelet/ddev-playwright +# + +export DISPLAY=:1 + +unset SESSION_MANAGER +unset DBUS_SESSION_BUS_ADDRESS +OS=`uname -s` +if [ $OS = 'Linux' ]; then + case "$WINDOWMANAGER" in + *gnome*) + if [ -e /etc/SuSE-release ]; then + PATH=$PATH:/opt/gnome/bin + export PATH + fi + ;; + esac +fi +if [ -x /etc/X11/xinit/xinitrc ]; then + exec /etc/X11/xinit/xinitrc +fi +if [ -f /etc/X11/xinit/xinitrc ]; then + exec sh /etc/X11/xinit/xinitrc +fi +[ -r $HOME/.Xresources ] && xrdb $HOME/.Xresources +xterm -geometry 80x24+10+10 -ls -title "$VNCDESKTOP Desktop" & +icewm-session diff --git a/.gitattributes b/.gitattributes index e7b792f..337136c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -44,21 +44,3 @@ # PHPStan's baseline uses tabs instead of spaces. core/.phpstan-baseline.php text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tabwidth=2 diff=php linguist-language=php - -# Define binary file attributes. -# - Do not treat them as text. -# - Include binary diff in patches instead of "binary files differ." -*.eot -text diff -*.exe -text diff -*.gif -text diff -*.gz -text diff -*.ico -text diff -*.jpeg -text diff -*.jpg -text diff -*.otf -text diff -*.phar -text diff -*.png -text diff -*.svgz -text diff -*.ttf -text diff -*.woff -text diff -*.woff2 -text diff diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 6908667..54b9acb 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 2 @@ -24,4 +24,6 @@ jobs: 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 + env: + BASE_REF: ${{ github.base_ref }} + run: git diff --exit-code "origin/$BASE_REF" -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml new file mode 100644 index 0000000..82a1317 --- /dev/null +++ b/.github/workflows/javascript.yaml @@ -0,0 +1,39 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/drupal-module/javascript.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Drupal module 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: + paths: &paths + - "tests/**/*.{js,ts}" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + javascript-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier --no-editorconfig 'tests/**/*.{js,ts}' --check diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml index ce1d1e4..ac0fc21 100644 --- a/.github/workflows/markdown.yaml +++ b/.github/workflows/markdown.yaml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Create docker network run: | diff --git a/.github/workflows/project.yaml b/.github/workflows/module.yaml similarity index 63% rename from .github/workflows/project.yaml rename to .github/workflows/module.yaml index 13e8da9..28f0419 100644 --- a/.github/workflows/project.yaml +++ b/.github/workflows/module.yaml @@ -1,4 +1,4 @@ -name: Project +name: Module env: COMPOSE_USER: runner @@ -17,15 +17,18 @@ jobs: name: PHP runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: go-task/setup-task@v1 + - uses: actions/checkout@v7 # https://github.com/ddev/github-action-setup-ddev - uses: ddev/github-action-setup-ddev@v1 with: autostart: false - name: Start DDEV - run: task ddev:start + # https://github.com/ddev/ddev-drupal-contrib + run: | + ddev start + ddev poser --no-interaction --no-security-blocking + ddev symlink-project - name: Composer - validate run: ddev composer validate --strict @@ -36,25 +39,18 @@ jobs: - name: PHP coding standards run: ddev phpcs + - name: Twig coding standards + run: >- + [[ -z $(git ls-files '*.twig') ]] || + ddev exec vendor/bin/twig-cs-fixer + - name: Code analysis - run: task code-analysis:phpstan + run: ddev phpstan - name: Rector - run: task rector -- --dry-run + run: ddev php vendor/bin/rector --dry-run - translations: - name: Translations - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: go-task/setup-task@v1 - # https://github.com/ddev/github-action-setup-ddev - - uses: ddev/github-action-setup-ddev@v1 - with: - autostart: false - - - name: Start DDEV - run: task ddev:start + - uses: go-task/setup-task@v2 - name: Extract translations run: task translations:extract diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml index 4ed6ae3..c54cf8e 100644 --- a/.github/workflows/yaml.yaml +++ b/.github/workflows/yaml.yaml @@ -35,11 +35,11 @@ jobs: yaml-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Create docker network run: | docker network create frontend - run: | - docker compose run --rm prettier '**/*.{yml,yaml}' --check + docker compose run --rm prettier --no-editorconfig '**/*.{yml,yaml}' --check diff --git a/.gitignore b/.gitignore index 615548e..f5aeab7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ composer.lock vendor/ translations/*.mo +*.local # DDEV stuff -web/ -recipes/ +/web/ +/recipes/ diff --git a/.markdownlintignore b/.markdownlintignore index 6311097..747414b 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -1,11 +1,17 @@ -# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker. +# This file is copied from config/drupal-module-ddev/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/ LICENSE.md - +# Python +.venv/ +# Drupal +web/*.md +web/core/ +web/libraries/ +web/*/contrib/ # DDEV stuff -web/ -recipes/ +/web/ +/recipes/ diff --git a/.prettierignore b/.prettierignore index 3a4f5cc..fc24e0c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,7 @@ +# This file is copied from config/drupal-module-ddev/prettier/.prettierignore 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. + +# Ignore all Drupal config since it is nearly all autogenerated by config-export +config/ +# Ignore all ddev files .ddev/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e91036..16666ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2026-09-28 +* [PR-10](https://github.com/itk-dev/os2forms_f2/pull/10) + Updated user manual +* [PR-8](https://github.com/itk-dev/os2forms_f2/pull/8) + Added "Archive on case" * [PR-7](https://github.com/itk-dev/os2forms_f2/pull/7) Added translations and user manual * [PR-3](https://github.com/itk-dev/os2forms_f2/pull/3) diff --git a/README.md b/README.md index 2a35def..8af8a7b 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,91 @@ # OS2Forms: F2 integration +This module add a F2 webform handler that can archive content from a OS2Forms Attachment webform element in +[F2](https://www.cbrain.com/software-pages/the-f2-platform). + +The handler can achive on a case and on a matter. Documents in F2 can only be attached to matters, so when archiving on +a case, the handler first creates a matter before attaching a document to it. + +## Installation + 1. Install the module ``` shell drush pm:install os2forms_f2 ``` -2. Go to `/admin/os2forms_f2/settings` and define settings. -3. Add a "F2" handler to a webform. +2. Go to `/admin/os2forms_f2/settings` and define settings for connecting to the F2 API + +> [!TIP] +> Use the `os2forms-f2:api-client` Drush command to check that the API connection works as expected. Run +> +> ```shell +> drush os2forms-f2:api-client --help +> ``` +> +> for some help and examples on how to use the command. + +## Usage + +When the F2 module has been installed and configured, an F2 handler can be added to a webform. See the [user manual for administrators](docs/user-manual/da/administrator.md) and [for form -builders](docs/user-manual/da/form-builder.md) for more details (both are in Danish). +builders](docs/user-manual/da/form-builder.md) for more details on how to use the handler (both are in Danish). + +### Examples + +Install the F2 form examples module (`drush pm:install os2forms_f2_examples`) to load an example webform with F2 +handlers. ## Development -We use [DDEV Drupal Contrib](https://github.com/ddev/ddev-drupal-contrib) for development[^1]. +> [!IMPORTANT] +> 2026-10-30: We lock `phpstan/phpstan` to version `2.3.3` as a temporary workaround for the issue reported in +> . +> +> We use [palantirnet/drupal-rector](https://github.com/palantirnet/drupal-rector) to run +> [Rector](https://github.com/rectorphp/rector). `drupal-rector` pulls in `rectorphp/rector`, but [conflicts with +> `"rector/rector": +> ">=2.6.2"`](https://github.com/palantirnet/drupal-rector/blob/3c584968fc61d55fdfce4e88b4e95841a60f3ff9/composer.json#L15-L17) +> and therefore we cannot (yet) upgrade to `rector/rector:^2.8.3`. +> +> See for details. + +We use [DDEV Drupal Contrib](https://addons.ddev.com/addons/ddev/ddev-drupal-contrib) for development. + +
+Details on the DDEV setup + +The DDEV environment has been created by following the template on : + +```shell name=ddev-create +ddev config --project-type=drupal11 --docroot=web +ddev add-on get ddev/ddev-drupal-contrib +ddev add-on get julienloizelet/ddev-playwright +ddev start +ddev poser --no-interaction +ddev symlink-project +# Detect expected Drupal and PHP versions. +ddev config --update +``` -[^1]: The DDEV environment has been created by running +We have a dependency on [OS2Forms](https://github.com/OS2Forms/os2forms) which requires [Drupal +Lenient](https://github.com/mglaman/composer-drupal-lenient) to install some outdated modules, and therefore we use [a +DDEV hook](https://docs.ddev.com/en/stable/users/configuration/hooks/) to make sure that Drupal Lenient is installed +before we run any actual `composer install` commands: + +``` yaml +# .ddev/config.yaml +hooks: + post-start: + # Install Drupal Lenient (https://github.com/mglaman/composer-drupal-lenient) + - exec: COMPOSER=composer.lenient.json composer init --no-interaction + - exec: COMPOSER=composer.lenient.json composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true + - exec: COMPOSER=composer.lenient.json composer require mglaman/composer-drupal-lenient:^1.0 + - exec: rm -f composer.lenient.* +``` - ```shell - ddev config --project-type=drupal10 --docroot=web - ddev dotenv set .ddev/.env.web --drupal-core '^10.5.10' - ``` +
Start the show by running @@ -55,3 +119,70 @@ task translations:git:restore to discard any non-essential changes to translation files (e.g. timestamps being updated without any actual translation changes). + +## Testing + +``` shell +task drupal:os2forms:install +``` + +## User manual + +The Danish user manual for the F2 module sits in [`docs/user-manual/da/`](docs/user-manual/da/). + +We use [Playwright](https://playwright.dev/) to generate images for the user manual – and perform some testing along the +way – and we use [ddev-playwright](https://addons.ddev.com/addons/julienloizelet/ddev-playwright) to run Playwright in +the DDEV environment. + +Before generating the images a +little setup must be added in `tests/Playwright/.env.local`: + +``` dotenv +# Settings used to connect to a real F2 API +F2_API_URI= +F2_API_USERNAME= +F2_API_SECRET= +F2_API_F2_USERNAME= + +# An exiting case identifier +F2_CASE_IDENTIFIER= + +# An exiting matter identifier +F2_MATTER_IDENTIFIER='903' +``` + +With these in place, we can now generate images: + +``` shell +task drupal:os2forms:install --yes +task playwright:install --yes +task user-manual:images:generate +``` + +> [!TIP] +> If something fails, run +> +> ``` shell +> open tests/Playwright/playwright-report/index.html +> ``` +> +> to see an error report. + +When editing the user manual and fooling around with Playwright to generate useful images, you can run + +``` shell +task user-manual:images:generate +``` + +to quickly check that everything works as expected, but before committing anything you must run + +``` shell +task drupal:os2forms:install --yes +task user-manual:images:generate +``` + +reset Drupal before generating the images. + +> [!TIP] +> See [Run a Playwright command](https://addons.ddev.com/addons/julienloizelet/ddev-playwright#run-a-playwright-command) +> for other cool tricks supported by DDEV and the Playwright add-on. diff --git a/Taskfile.yml b/Taskfile.yml index 009ab4d..0b42d89 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,6 +16,7 @@ tasks: desc: "Apply coding standards" cmds: - task: coding-standards:composer:apply + - task: coding-standards:javascript:apply - task: coding-standards:markdown:apply - task: coding-standards:php:apply - task: coding-standards:yaml:apply @@ -24,6 +25,7 @@ tasks: desc: "Check coding standards" cmds: - task: coding-standards:composer:check + - task: coding-standards:javascript:check - task: coding-standards:markdown:check - task: coding-standards:php:check - task: coding-standards:yaml:check @@ -44,6 +46,19 @@ tasks: vars: TASK_ARGS: composer validate + coding-standards:javascript:apply: + desc: "Apply coding standards for JavaScript" + cmds: + # Cf. .github/workflows/javascript.yaml + - docker compose run --rm prettier 'tests/**/*.{js,ts}' --write + + coding-standards:javascript:check: + desc: "Apply coding standards for JavaScript" + cmds: + - task: coding-standards:javascript:apply + # Cf. .github/workflows/javascript.yaml + - docker compose run --rm prettier 'tests/**/*.{js,ts}' --check + coding-standards:markdown:apply: desc: "Apply coding standards for Markdown" cmds: @@ -69,6 +84,8 @@ tasks: desc: "Apply and check coding standards for PHP" cmds: - task: coding-standards:php:apply + # phpcbf has weird exit codes … + ignore_error: true # Cf. .github/workflows/php.yaml - task: ddev vars: @@ -100,7 +117,7 @@ tasks: vars: TASK_ARGS: phpstan - rector: + code-analysis:rector: desc: "Run rector" cmds: - task: ddev @@ -108,38 +125,37 @@ tasks: TASK_ARGS: php vendor/bin/rector ddev:start: - desc: Start DDEV setup (https://github.com/ddev/ddev-drupal-contrib) - # https://github.com/ddev/ddev-drupal-contrib - deps: - - composer:install:lenient + desc: Start DDEV Drupal Contrib setup (https://github.com/ddev/ddev-drupal-contrib) cmds: - - ddev add-on get ddev/ddev-drupal-contrib - - ddev restart + # We want a clean slate + - rm -f composer.lock + - ddev start - ddev poser --no-interaction - ddev symlink-project - # Detect expected Drupal and PHP versions. - - ddev config --update - composer:install:lenient: + ddev:update: + desc: Update DDEV Drupal Contrib setup (https://github.com/ddev/ddev-drupal-contrib#update) cmds: - # Create a temporary composer file to install https://github.com/mglaman/composer-drupal-lenient before the real install needs it. - - for: - - init --no-interaction - - config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true - - require mglaman/composer-drupal-lenient:^1.0 - cmd: ddev exec -- COMPOSER=composer.lenient.json composer {{.ITEM}} - # Clean up - - rm -f composer.lenient.* - internal: true + - ddev add-on get ddev/ddev-drupal-contrib + - ddev restart translations:extract: desc: Extract module translations cmds: + # Patch https://github.com/OS2Forms/os2forms/blob/develop/config/optional/pathauto.pattern.os2forms_wf_webforms.yml + - | + ddev exec sed --in-place 's/id: node_type$/id: entity_bundle:node/' */modules/contrib/os2forms/config/optional/pathauto.pattern.os2forms_wf_webforms.yml - ddev drush --yes site:install minimal - ddev drush --yes pm:install drupal_translation_extractor + - ddev drush --yes pm:install os2forms_f2 - for: - da - cmd: ddev drush --yes drupal_translation_extractor:translation:extract {{.ITEM}} --dump-messages --force --output=%source/translations/%module.%locale.po --require-context=os2forms_f2 module:os2forms_f2 --debug + cmd: >- + ddev drush --yes drupal_translation_extractor:translation:extract {{.ITEM}} + --dump-messages --force --output=%source/translations/%module.%locale.po + --require-context=os2forms_f2 module:os2forms_f2 + --sort=asc --clean --debug + --finder-do-not-ignore-vcs-ignored translations:git:diff: desc: "`git diff` all translation files ignoring timestamp changes" @@ -163,3 +179,115 @@ tasks: to check. cmd: git restore '*.po' + + drupal:os2forms:install: + desc: Install OS2Forms F2 module and configure Drupal for testing + prompt: Really reset Drupal site? + cmds: + - ddev drush --yes site:install + - ddev drush --yes user:password admin password + - ddev drush --yes pm:uninstall update + + # Patch https://github.com/OS2Forms/os2forms/blob/develop/config/optional/pathauto.pattern.os2forms_wf_webforms.yml + - | + ddev exec sed --in-place 's/id: node_type$/id: entity_bundle:node/' */modules/contrib/os2forms/config/optional/pathauto.pattern.os2forms_wf_webforms.yml + + - ddev drush --yes pm:install os2forms_f2 os2forms_f2_examples os2forms_test_playwright + + # Gin theme + - ddev drush --yes theme:install gin + - ddev drush --yes config:set system.theme default gin + - ddev drush --yes config:set system.theme admin gin + - ddev drush --yes pm:uninstall navigation + # - ddev drush --yes config:set gin.settings classic_toolbar horizontal + + - ddev drush --yes config:set webform.settings ui.description_help false --input-format yaml + - ddev drush --yes config:set webform.settings settings.default_submission_log true --input-format=yaml + + # Languages and locales + - ddev drush --yes pm:install language locale + - ddev drush --yes language:add da + + # - ddev drush --yes locale:check + # - ddev drush --yes locale:update + # Import our module translations + - ddev drush --yes locale:import da modules/custom/os2forms_f2/translations/os2forms_f2.da.po + + - task: drupal:os2forms:configure + + drupal:os2forms:configure: + decs: Create settings for os2forms_f2 module + cmds: + # Some defaults + - ddev drush --yes config:set os2forms_f2.settings f2_api.log_level debug + - ddev drush --yes config:set os2forms_f2.settings general.queue os2forms_f2 + + playwright:install: + desc: Install Playwright stuff + cmd: ddev playwright-install --pm npm + + user-manual:images:generate: + desc: Generate user manual images + cmds: + - cmd: ddev playwright test user-manual-images.spec.ts {{.CLI_ARGS}} + # We want to postprocess any updated images even if something fails + ignore_error: true + - task: user-manual:images:trim + - task: user-manual:images:restore + + user-manual:images:trim: + desc: "Trim docs images" + vars: + BORDER_WIDTH: 4 + # Trim and add border + cmd: + cmd: | + docker run --volume $PWD:/app --workdir /app --rm itkdev/php8.5-fpm mogrify \ + -strip \ + -background white \ + -trim \ + -gravity north -splice 0x{{.BORDER_WIDTH}}+0+0 \ + -gravity south -splice 0x{{.BORDER_WIDTH}}+0+0 \ + -gravity west -splice {{.BORDER_WIDTH}}x0+0+0 \ + -gravity east -splice {{.BORDER_WIDTH}}x0+0+0 \ + docs/user-manual/da/images/generated/**/*.png + # https://taskfile.dev/docs/reference/schema#shopt + shopt: [globstar] + + user-manual:images:restore: + desc: "Restore docs images that are not visually modified" + cmd: + cmd: | + # https://stackoverflow.com/a/949391 + latest_commit=$(git rev-parse --verify HEAD) + + # M docs/en/images/manager/006-entry-look-up.png + status_modified_pattern="^ M " + + debug_output_filename=false + if [[ '{{.COMPARE_OUTPUT_FILENAME | default "-"}}' != '-' ]]; then + debug_output_filename=true + fi + + shopt -s globstar nullglob + for file in docs/user-manual/da/images/generated/**/*.png; do + status=$(git status --porcelain "$file") + if [[ "$status" =~ $status_modified_pattern ]]; then + if git cat-file -p "$latest_commit":"$file" | \ + docker run --volume $PWD:/app --workdir /app --rm itkdev/php8.5-fpm compare \ + - \ + "$file" \ + {{.COMPARE_OUTPUT_FILENAME | default "-"}} > /dev/null 2>&1; then + echo "Restoring $file" + git restore "$file" + else + echo "$file modified" + if [ "$debug_output_filename" = true ]; then + echo + echo "Comparison image written to {{.COMPARE_OUTPUT_FILENAME | default "-"}}" + echo + fi + fi + fi + done + shopt: [globstar] diff --git a/composer.json b/composer.json index 8a8dd3b..8161b38 100644 --- a/composer.json +++ b/composer.json @@ -10,22 +10,22 @@ } ], "require": { - "php": "^8.3", - "drush/drush": "^12 || ^13", + "php": "^8.4", + "drush/drush": "^13.8", "itk-dev/drupal_psr6_cache": "^1.1", - "itk-dev/f2-api-client": "dev-f2-api-client as 1.0.0", - "os2forms/os2forms": "^5.1" + "itk-dev/f2-api-client": "^1.0", + "os2forms/os2forms": "^6.1" }, "require-dev": { - "drupal/coder": "^8.3", - "ergebnis/composer-normalize": "^2.52", - "itk-dev/drupal_translation_extractor": "^1.4", + "drupal/coder": "^9.0", + "ergebnis/composer-normalize": "^2.54", + "itk-dev/drupal_translation_extractor": "^2.0", "mglaman/composer-drupal-lenient": "^1.0", - "mglaman/phpstan-drupal": "^1.1", - "palantirnet/drupal-rector": "0.x-dev", + "mglaman/phpstan-drupal": "^2.2", + "palantirnet/drupal-rector": "^1.1", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-deprecation-rules": "^1.0" + "phpstan/phpstan": "2.3.3", + "phpstan/phpstan-deprecation-rules": "^2.0" }, "repositories": [ { @@ -47,8 +47,9 @@ "mglaman/composer-drupal-lenient": true, "php-http/discovery": true, "phpstan/extension-installer": true, - "simplesamlphp/composer-module-installer": false, - "simplesamlphp/composer-xmlprovider-installer": false, + "simplesamlphp/composer-module-installer": true, + "simplesamlphp/composer-xmlprovider-installer": true, + "symfony/runtime": true, "tbachert/spi": true, "zaporylie/composer-drupal-optimizations": true }, @@ -62,7 +63,9 @@ "drupal-lenient": { "allowed-list": [ "drupal/coc_forms_auto_export", - "drupal/webform_node_element" + "drupal/webform_node_element", + "drupal/events_logging", + "drupal/switch_page_theme" ] } } diff --git a/compose.yaml b/docker-compose.yml similarity index 63% rename from compose.yaml rename to docker-compose.yml index 1a102e2..da941b7 100644 --- a/compose.yaml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +# itk-version: 3.2.4 + services: # Code checks tools markdownlint: @@ -9,9 +11,9 @@ services: prettier: # Prettier does not (yet, cf. - # https://github.com/prettier/prettier/issues/15206) have an official docker - # image. https://hub.docker.com/r/jauderho/prettier is good candidate (cf. - # https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc) + # https://github.com/prettier/prettier/issues/15206) have an official + # docker image. + # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc) image: jauderho/prettier profiles: - dev diff --git a/docs/user-manual/da/administrator.md b/docs/user-manual/da/administrator.md index 0807b16..8caea96 100644 --- a/docs/user-manual/da/administrator.md +++ b/docs/user-manual/da/administrator.md @@ -1,8 +1,18 @@ # Administrator Generelle indstillinger for F2-modulet laves under Administration > Indstillinger > OS2Forms F2 -(`/admin/os2forms_f2/settings`), fx +(`/admin/os2forms_f2/settings`): -![F2-indstillinger](./images/f2-indstillinger.png) +![F2-indstillinger](./images/generated/administrator/f2-settings-empty.png) -Passende værdier for "Url", "Bruger", "Hemmelighed" og "F2-brugernavn" kan udleveres af en F2-administrator. +Passende værdier for "Url", "Bruger", "Hemmelighed" og "F2-brugernavn" kan udleveres af en F2-administrator, fx + +![Udfyldte F2-indstillinger](./images/generated/administrator/f2-settings-filled.png) + +Når indstillingerne er gemt kan man tjekke at der er hul igennem til api'et ved at trykke på "Tjek forbindelse til api": + +![Fejl under forbindelse til F2-api](./images/generated/administrator/f2-settings-api-pinged-error.png) + +Når man indsætter gyldige og korrekte værdier skulle der gerne være hul igennem til api'et: + +![Forbindelse til F2-api oprettet](./images/generated/administrator/f2-settings-api-pinged-success.png) diff --git a/docs/user-manual/da/form-builder.md b/docs/user-manual/da/form-builder.md index 9ec5224..2b4db94 100644 --- a/docs/user-manual/da/form-builder.md +++ b/docs/user-manual/da/form-builder.md @@ -1,18 +1,72 @@ # Formularbygger -F2-behandleren kan arkivere indholdet i et vedhæftningselement (OS2Forms Attachment) i F2. Behandleren indsættes ligesom -andre behandlere og konfigureres på passende vis. +F2-behandleren kan arkivere indholdet i et vedhæftningselement (OS2Forms Attachment) som et dokument i F2. Behandleren +indsættes ligesom andre behandlere -![F2-behandler](./images/f2-handler.png) +![Tilføj F2-formularbehandler](./images/generated/form-builder/webform-handler-select-f2.png) -Akt-id'et skal udpege en akt der allerede er oprettet i F2, og når man gemmer indstillingerne kontrolles at akten -findes og i tilfælde af fejl vises en passende fejlbesked: +og konfigureres på passende vis. -![F2-behandler med fejl](./images/f2-handler-error.png) +![Formularbehandler](./images/generated/form-builder/webform-handler-add-general.png) + +De afgørende indstillinger for F2-behandleren ligger under "F2-arkiveringsindstillinger": + +![F2-arkiveringsindstillinger](./images/generated/form-builder/webform-handler-add-general-archive-settings.png) + +Vælg et vedhæftningselement (af typen "OS2Forms Attachment") og angiv en titel på dokumentet der arkiveres i F2: + +![Udfyldte arkiveringsindstillinger](./images/generated/form-builder/webform-handler-add-general-archive-settings-filled.png) + +Under "Arkivér på" kan man vælge "[Sag](#arkivering-på-sag)" eller "[Akt](#arkivering-på-akt)" og valget har indflydelse +på yderligere muligheder i behandlerindstillingerne. + +## Arkivering på sag + +Som bekendt ligger dokumenter i F2 på en akt, og for at arkivere på en sag opretter behandleren derfor indledningsvist +en akt som dokumentet kan knyttes til. For at arkivere på en sag skal man angive et id der entydigt udpeger en sag i F2. +Det vil være oplagt at bruge et sags-nummer + +![Arkivér på sag](./images/generated/form-builder/webform-handler-add-archive-on-case.png) + +![Udfyldt "Arkivér på sag"](./images/generated/form-builder/webform-handler-add-archive-on-case-filled.png) + +Når man gemmer tjekker systemet at sags-id'et entydigt identificerer en sag i F2: + +![Ugyldigt sags-id](./images/generated/form-builder/webform-handler-add-archive-on-case-filled-invalid-case-identifier.png) + +Hvis id'et entydigt identificerer en sag i F2, så har man med succes tilføjet en F2-behandler: + +![Gyldigt og korrekt sags-id](./images/generated/form-builder/webform-handler-add-archive-on-case-filled-valid-case-identifier.png) + +!["Arkivér på sag"-behandler tilføjet](./images/generated/form-builder/webform-handler-added.png) + +Hvis man, som antydet, redigerer en F2-behandler kan man se detaljer om hvilken sag der faktisk arkiveres på: + +![Detaljer om sag](./images/generated/form-builder/webform-handler-archive-on-case-case-details.png) + +## Arkivering på akt + +Når man vil arkivere på en (eksisterende) akt skal man angive et id, fx akt-nummer, der entydigt udpeger akten i F2. + +![Arkivér på akt](./images/generated/form-builder/webform-handler-add-archive-on-matter.png) + +Når man gemmer tjekker systemet at akt-id'et entydigt identificerer en akt i F2: + +![Ugyldigt sags-id](./images/generated/form-builder/webform-handler-add-archive-on-matter-filled-invalid-matter-identifier.png) + +Hvis id'et entydigt identificerer en sag i F2, så har man med succes tilføjet en F2-behandler: + +!["Arkivér på akt"-behandler tilføjet](./images/generated/form-builder/webform-handler-added-2.png) + +Når man redigerer behandleren, kan man se detaljer om den akt der arkiveres på: + +![Detaljer om akt](./images/generated/form-builder/webform-handler-archive-on-matter-matter-details.png) + +## Avancerede indstillinger På fanen "Avanceret" kan man styre hvornår behandleren skal arkivere i F2: -![Avancerede indstillinger for F2-behandler](./images/f2-handler-advanced.png) +![Avancerede indstillinger for F2-behandler](./images/generated/form-builder/webform-handler-add-advanced.png) > [!NOTE] > Standardindstillingen, "Kør når … besvarelse afsluttes", vil oftest være det rigtige valg og kun i sjældne tilfælde diff --git a/docs/user-manual/da/images/f2-handler-advanced.png b/docs/user-manual/da/images/f2-handler-advanced.png deleted file mode 100644 index 7ab5d87..0000000 Binary files a/docs/user-manual/da/images/f2-handler-advanced.png and /dev/null differ diff --git a/docs/user-manual/da/images/f2-handler-error.png b/docs/user-manual/da/images/f2-handler-error.png deleted file mode 100644 index 4561c37..0000000 Binary files a/docs/user-manual/da/images/f2-handler-error.png and /dev/null differ diff --git a/docs/user-manual/da/images/f2-handler.png b/docs/user-manual/da/images/f2-handler.png deleted file mode 100644 index ab5a3d3..0000000 Binary files a/docs/user-manual/da/images/f2-handler.png and /dev/null differ diff --git a/docs/user-manual/da/images/f2-indstillinger.png b/docs/user-manual/da/images/f2-indstillinger.png deleted file mode 100644 index dca0b7c..0000000 Binary files a/docs/user-manual/da/images/f2-indstillinger.png and /dev/null differ diff --git a/docs/user-manual/da/images/generated/administrator/f2-settings-api-pinged-error.png b/docs/user-manual/da/images/generated/administrator/f2-settings-api-pinged-error.png new file mode 100644 index 0000000..a4a1594 Binary files /dev/null and b/docs/user-manual/da/images/generated/administrator/f2-settings-api-pinged-error.png differ diff --git a/docs/user-manual/da/images/generated/administrator/f2-settings-api-pinged-success.png b/docs/user-manual/da/images/generated/administrator/f2-settings-api-pinged-success.png new file mode 100644 index 0000000..aaa78d6 Binary files /dev/null and b/docs/user-manual/da/images/generated/administrator/f2-settings-api-pinged-success.png differ diff --git a/docs/user-manual/da/images/generated/administrator/f2-settings-empty.png b/docs/user-manual/da/images/generated/administrator/f2-settings-empty.png new file mode 100644 index 0000000..80e15e5 Binary files /dev/null and b/docs/user-manual/da/images/generated/administrator/f2-settings-empty.png differ diff --git a/docs/user-manual/da/images/generated/administrator/f2-settings-filled.png b/docs/user-manual/da/images/generated/administrator/f2-settings-filled.png new file mode 100644 index 0000000..c696722 Binary files /dev/null and b/docs/user-manual/da/images/generated/administrator/f2-settings-filled.png differ diff --git a/docs/user-manual/da/images/generated/administrator/f2-settings-saved.png b/docs/user-manual/da/images/generated/administrator/f2-settings-saved.png new file mode 100644 index 0000000..acc7963 Binary files /dev/null and b/docs/user-manual/da/images/generated/administrator/f2-settings-saved.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-advanced.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-advanced.png new file mode 100644 index 0000000..796a6bc Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-advanced.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled-invalid-case-identifier.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled-invalid-case-identifier.png new file mode 100644 index 0000000..40c2c5a Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled-invalid-case-identifier.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled-valid-case-identifier.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled-valid-case-identifier.png new file mode 100644 index 0000000..29319d9 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled-valid-case-identifier.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled.png new file mode 100644 index 0000000..c381b97 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case-filled.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case.png new file mode 100644 index 0000000..9b9d70c Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-case.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled-invalid-matter-identifier.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled-invalid-matter-identifier.png new file mode 100644 index 0000000..65c1621 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled-invalid-matter-identifier.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled-valid-matter-identifier.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled-valid-matter-identifier.png new file mode 100644 index 0000000..29319d9 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled-valid-matter-identifier.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled.png new file mode 100644 index 0000000..663df58 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter-filled.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter.png new file mode 100644 index 0000000..5283ad1 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-archive-on-matter.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general-archive-settings-filled.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general-archive-settings-filled.png new file mode 100644 index 0000000..be6173e Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general-archive-settings-filled.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general-archive-settings.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general-archive-settings.png new file mode 100644 index 0000000..a83e5d5 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general-archive-settings.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general.png new file mode 100644 index 0000000..2e02c50 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-add-general.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-added-2.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-added-2.png new file mode 100644 index 0000000..6d4e344 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-added-2.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-added.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-added.png new file mode 100644 index 0000000..f294bdd Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-added.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case-case-details.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case-case-details.png new file mode 100644 index 0000000..dbe95fa Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case-case-details.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case-case.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case-case.png new file mode 100644 index 0000000..fac18b5 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case-case.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case.png new file mode 100644 index 0000000..d6fe39a Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-case.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter-matter-details.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter-matter-details.png new file mode 100644 index 0000000..142f988 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter-matter-details.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter-matter.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter-matter.png new file mode 100644 index 0000000..28bb95c Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter-matter.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter.png new file mode 100644 index 0000000..da06ea7 Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-archive-on-matter.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handler-select-f2.png b/docs/user-manual/da/images/generated/form-builder/webform-handler-select-f2.png new file mode 100644 index 0000000..240ffdb Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handler-select-f2.png differ diff --git a/docs/user-manual/da/images/generated/form-builder/webform-handlers.png b/docs/user-manual/da/images/generated/form-builder/webform-handlers.png new file mode 100644 index 0000000..75aaebd Binary files /dev/null and b/docs/user-manual/da/images/generated/form-builder/webform-handlers.png differ diff --git a/modules/os2forms_f2_examples/config/install/webform.webform.os2forms_f2_ex_000.yml b/modules/os2forms_f2_examples/config/install/webform.webform.os2forms_f2_ex_000.yml new file mode 100644 index 0000000..2df0929 --- /dev/null +++ b/modules/os2forms_f2_examples/config/install/webform.webform.os2forms_f2_ex_000.yml @@ -0,0 +1,216 @@ +langcode: da +status: open +dependencies: + module: + - os2forms_f2 + enforced: + module: + - os2forms_f2_examples +weight: 0 +open: null +close: null +uid: 0 +template: false +archive: false +id: os2forms_f2_ex_000 +title: F2 +description: null +categories: { } +elements: |- + name: + '#type': textfield + '#title': Name + '#required': true + attachment: + '#type': os2forms_attachment + '#title': attachment + '#export_type': pdf + '#digital_signature': 0 + '#excluded_elements': { } + '#exclude_empty': 0 + '#exclude_empty_checkbox': 0 +css: '' +javascript: '' +settings: + ajax: false + ajax_scroll_top: form + ajax_progress_type: '' + ajax_effect: '' + ajax_speed: null + page: true + page_submit_path: '' + page_confirm_path: '' + page_theme_name: '' + form_title: both + form_submit_once: false + form_open_message: '' + form_close_message: '' + form_exception_message: '' + form_previous_submissions: true + form_confidential: false + form_confidential_message: '' + form_disable_remote_addr: false + form_convert_anonymous: false + form_prepopulate: false + form_prepopulate_source_entity: false + form_prepopulate_source_entity_required: false + form_prepopulate_source_entity_type: '' + form_unsaved: false + form_disable_back: false + form_submit_back: false + form_disable_autocomplete: false + form_novalidate: false + form_disable_inline_errors: false + form_required: false + form_autofocus: false + form_details_toggle: false + form_reset: false + form_access_denied: default + form_access_denied_title: '' + form_access_denied_message: '' + form_access_denied_attributes: { } + form_file_limit: '' + form_attributes: { } + form_method: '' + form_action: '' + share: false + share_node: false + share_theme_name: '' + share_title: true + share_page_body_attributes: { } + submission_label: '' + submission_exception_message: '' + submission_locked_message: '' + submission_log: false + submission_excluded_elements: { } + submission_exclude_empty: false + submission_exclude_empty_checkbox: false + submission_views: { } + submission_views_replace: { } + submission_user_columns: { } + submission_user_duplicate: false + submission_access_denied: default + submission_access_denied_title: '' + submission_access_denied_message: '' + submission_access_denied_attributes: { } + previous_submission_message: '' + previous_submissions_message: '' + autofill: false + autofill_message: '' + autofill_excluded_elements: { } + wizard_progress_bar: true + wizard_progress_pages: false + wizard_progress_percentage: false + wizard_progress_link: false + wizard_progress_states: false + wizard_start_label: '' + wizard_preview_link: false + wizard_confirmation: true + wizard_confirmation_label: '' + wizard_auto_forward: true + wizard_auto_forward_hide_next_button: false + wizard_keyboard: true + wizard_track: '' + wizard_prev_button_label: '' + wizard_next_button_label: '' + wizard_toggle: false + wizard_toggle_show_label: '' + wizard_toggle_hide_label: '' + wizard_page_type: container + wizard_page_title_tag: h2 + preview: 0 + preview_label: '' + preview_title: '' + preview_message: '' + preview_attributes: { } + preview_excluded_elements: { } + preview_exclude_empty: true + preview_exclude_empty_checkbox: false + draft: none + draft_multiple: false + draft_auto_save: false + draft_saved_message: '' + draft_loaded_message: '' + draft_pending_single_message: '' + draft_pending_multiple_message: '' + confirmation_type: page + confirmation_url: '' + confirmation_title: '' + confirmation_message: '' + confirmation_attributes: { } + confirmation_back: true + confirmation_back_label: '' + confirmation_back_attributes: { } + confirmation_exclude_query: false + confirmation_exclude_token: false + confirmation_update: false + limit_total: null + limit_total_interval: null + limit_total_message: '' + limit_total_unique: false + limit_user: null + limit_user_interval: null + limit_user_message: '' + limit_user_unique: false + entity_limit_total: null + entity_limit_total_interval: null + entity_limit_user: null + entity_limit_user_interval: null + purge: all + purge_days: 30 + results_disabled: false + results_disabled_ignore: false + results_customize: false + token_view: false + token_update: false + token_delete: false + serial_disabled: false +access: + create: + roles: + - anonymous + - authenticated + users: { } + permissions: { } + view_any: + roles: { } + users: { } + permissions: { } + update_any: + roles: { } + users: { } + permissions: { } + delete_any: + roles: { } + users: { } + permissions: { } + purge_any: + roles: { } + users: { } + permissions: { } + view_own: + roles: { } + users: { } + permissions: { } + update_own: + roles: { } + users: { } + permissions: { } + delete_own: + roles: { } + users: { } + permissions: { } + administer: + roles: { } + users: { } + permissions: { } + test: + roles: { } + users: { } + permissions: { } + configuration: + roles: { } + users: { } + permissions: { } +handlers: { } +variants: { } diff --git a/modules/os2forms_f2_examples/config/install/webform.webform.os2forms_f2_ex_001.yml b/modules/os2forms_f2_examples/config/install/webform.webform.os2forms_f2_ex_001.yml new file mode 100644 index 0000000..2aaf858 --- /dev/null +++ b/modules/os2forms_f2_examples/config/install/webform.webform.os2forms_f2_ex_001.yml @@ -0,0 +1,254 @@ +langcode: da +status: open +dependencies: + module: + - os2forms_f2 + enforced: + module: + - os2forms_f2_examples +weight: 0 +open: null +close: null +uid: 0 +template: false +archive: false +id: os2forms_f2_ex_001 +title: F2 +description: null +categories: { } +elements: |- + name: + '#type': textfield + '#title': Name + '#required': true + attachment: + '#type': os2forms_attachment + '#title': attachment + '#export_type': pdf + '#digital_signature': 0 + '#excluded_elements': { } + '#exclude_empty': 0 + '#exclude_empty_checkbox': 0 +css: '' +javascript: '' +settings: + ajax: false + ajax_scroll_top: form + ajax_progress_type: '' + ajax_effect: '' + ajax_speed: null + page: true + page_submit_path: '' + page_confirm_path: '' + page_theme_name: '' + form_title: both + form_submit_once: false + form_open_message: '' + form_close_message: '' + form_exception_message: '' + form_previous_submissions: true + form_confidential: false + form_confidential_message: '' + form_disable_remote_addr: false + form_convert_anonymous: false + form_prepopulate: false + form_prepopulate_source_entity: false + form_prepopulate_source_entity_required: false + form_prepopulate_source_entity_type: '' + form_unsaved: false + form_disable_back: false + form_submit_back: false + form_disable_autocomplete: false + form_novalidate: false + form_disable_inline_errors: false + form_required: false + form_autofocus: false + form_details_toggle: false + form_reset: false + form_access_denied: default + form_access_denied_title: '' + form_access_denied_message: '' + form_access_denied_attributes: { } + form_file_limit: '' + form_attributes: { } + form_method: '' + form_action: '' + share: false + share_node: false + share_theme_name: '' + share_title: true + share_page_body_attributes: { } + submission_label: '' + submission_exception_message: '' + submission_locked_message: '' + submission_log: false + submission_excluded_elements: { } + submission_exclude_empty: false + submission_exclude_empty_checkbox: false + submission_views: { } + submission_views_replace: { } + submission_user_columns: { } + submission_user_duplicate: false + submission_access_denied: default + submission_access_denied_title: '' + submission_access_denied_message: '' + submission_access_denied_attributes: { } + previous_submission_message: '' + previous_submissions_message: '' + autofill: false + autofill_message: '' + autofill_excluded_elements: { } + wizard_progress_bar: true + wizard_progress_pages: false + wizard_progress_percentage: false + wizard_progress_link: false + wizard_progress_states: false + wizard_start_label: '' + wizard_preview_link: false + wizard_confirmation: true + wizard_confirmation_label: '' + wizard_auto_forward: true + wizard_auto_forward_hide_next_button: false + wizard_keyboard: true + wizard_track: '' + wizard_prev_button_label: '' + wizard_next_button_label: '' + wizard_toggle: false + wizard_toggle_show_label: '' + wizard_toggle_hide_label: '' + wizard_page_type: container + wizard_page_title_tag: h2 + preview: 0 + preview_label: '' + preview_title: '' + preview_message: '' + preview_attributes: { } + preview_excluded_elements: { } + preview_exclude_empty: true + preview_exclude_empty_checkbox: false + draft: none + draft_multiple: false + draft_auto_save: false + draft_saved_message: '' + draft_loaded_message: '' + draft_pending_single_message: '' + draft_pending_multiple_message: '' + confirmation_type: page + confirmation_url: '' + confirmation_title: '' + confirmation_message: '' + confirmation_attributes: { } + confirmation_back: true + confirmation_back_label: '' + confirmation_back_attributes: { } + confirmation_exclude_query: false + confirmation_exclude_token: false + confirmation_update: false + limit_total: null + limit_total_interval: null + limit_total_message: '' + limit_total_unique: false + limit_user: null + limit_user_interval: null + limit_user_message: '' + limit_user_unique: false + entity_limit_total: null + entity_limit_total_interval: null + entity_limit_user: null + entity_limit_user_interval: null + purge: all + purge_days: 30 + results_disabled: false + results_disabled_ignore: false + results_customize: false + token_view: false + token_update: false + token_delete: false + serial_disabled: false +access: + create: + roles: + - anonymous + - authenticated + users: { } + permissions: { } + view_any: + roles: { } + users: { } + permissions: { } + update_any: + roles: { } + users: { } + permissions: { } + delete_any: + roles: { } + users: { } + permissions: { } + purge_any: + roles: { } + users: { } + permissions: { } + view_own: + roles: { } + users: { } + permissions: { } + update_own: + roles: { } + users: { } + permissions: { } + delete_own: + roles: { } + users: { } + permissions: { } + administer: + roles: { } + users: { } + permissions: { } + test: + roles: { } + users: { } + permissions: { } + configuration: + roles: { } + users: { } + permissions: { } +handlers: + f2_archive_on_matter: + id: os2forms_f2_f2 + handler_id: f2_archive_on_matter + label: F2 (arkivér på akt) + notes: '' + status: true + conditions: { } + weight: 0 + settings: + archive: + attachment_element: attachment + document_title: '[webform_submission:label] ([site:url]) – [current-date:html_datetime]' + archive_target: matter + archive_target_matter: + matter_identifier: '1234567890' + additional: + states: + - completed + f2_archive_on_case: + id: os2forms_f2_f2 + handler_id: f2_archive_on_case + label: 'F2 (arkivér på sag)' + notes: '' + status: true + conditions: { } + weight: 1 + settings: + archive: + attachment_element: attachment + document_title: '[webform_submission:label] ([site:url]) – [current-date:html_datetime]' + archive_target: case + archive_target_case: + case_identifier: '2026 - 1234567890' + matter_title: 'Matter: [webform_submission:label] ([site:url]) – [current-date:html_datetime]' + matter_type: Internal + additional: + states: + - completed +variants: { } diff --git a/modules/os2forms_f2_examples/os2forms_f2_examples.info.yml b/modules/os2forms_f2_examples/os2forms_f2_examples.info.yml new file mode 100644 index 0000000..ac9e7ae --- /dev/null +++ b/modules/os2forms_f2_examples/os2forms_f2_examples.info.yml @@ -0,0 +1,7 @@ +name: "F2 form examples" +type: module +description: "OS2Forms: F2 form examples" +package: OS2Forms +core_version_requirement: ^11 +dependencies: + - os2forms_f2:os2forms_f2 diff --git a/modules/os2forms_test_playwright/README.md b/modules/os2forms_test_playwright/README.md new file mode 100644 index 0000000..4ba8111 --- /dev/null +++ b/modules/os2forms_test_playwright/README.md @@ -0,0 +1,49 @@ +# OS2Forms test Playwright + +This module is used only for testing with [Playwright](https://playwright.dev/) and it provides a controller that can be +called to execute Drush commands. + +> [!IMPORTANT] +> Do not enable this module in production! + +The controller responds on `/os2forms-test-playwright/exec/drush` and to `POST` requests only. The content type *must* +be `application/json` and the body must be a JSON list of arguments and flags to Drush. + +## Installation + +``` shell +ddev drush pm:install os2forms_test_playwright +``` + +## Example + +Call `drush status`: + +``` shell +$ ddev exec curl http://localhost/os2forms-test-playwright/exec/drush --json '["status"]' +Drupal version : 11.4.11 +Site URI : https://os2forms-f2.ddev.site:30001 +DB driver : mysql +DB hostname : db +DB port : 3306 +DB username : db +DB name : db +Database : Connected +Drupal bootstrap : Successful +Default theme : gin +Admin theme : gin +PHP binary : /usr/bin/php8.4 +PHP config : /etc/php/8.4/cli/php.ini +PHP OS : Linux +PHP version : 8.4.27 +Drush script : /var/www/html/vendor/bin/drush.php +Drush version : 13.8.0.0 +Drush temp : /tmp +Drush configs : /var/www/html/vendor/drush/drush/drush.yml +Install profile : standard +Drupal root : /var/www/html/web +Site path : sites/default +Files, Public : sites/default/files +Files, Temp : /tmp +Drupal config : sites/default/files/sync +``` diff --git a/modules/os2forms_test_playwright/os2forms_test_playwright.info.yml b/modules/os2forms_test_playwright/os2forms_test_playwright.info.yml new file mode 100644 index 0000000..cec53ab --- /dev/null +++ b/modules/os2forms_test_playwright/os2forms_test_playwright.info.yml @@ -0,0 +1,5 @@ +name: "OS2Forms test: Playwright" +type: module +description: "Playwright test module for OS2Forms" +package: Custom +core_version_requirement: ^11 diff --git a/modules/os2forms_test_playwright/os2forms_test_playwright.routing.yml b/modules/os2forms_test_playwright/os2forms_test_playwright.routing.yml new file mode 100644 index 0000000..7c9263e --- /dev/null +++ b/modules/os2forms_test_playwright/os2forms_test_playwright.routing.yml @@ -0,0 +1,8 @@ +os2forms_test_playwright.exec: + path: "/os2forms-test-playwright/exec/{command}" + defaults: + _controller: '\Drupal\os2forms_test_playwright\Controller\ExecController' + requirements: + _permission: "access content" + methods: POST + no_cache: TRUE diff --git a/modules/os2forms_test_playwright/os2forms_test_playwright.services.yml b/modules/os2forms_test_playwright/os2forms_test_playwright.services.yml new file mode 100644 index 0000000..d4abfbb --- /dev/null +++ b/modules/os2forms_test_playwright/os2forms_test_playwright.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + + logger.channel.os2forms_test_playwright: + parent: logger.channel_base + arguments: ["os2forms_test_playwright"] diff --git a/modules/os2forms_test_playwright/src/Controller/ExecController.php b/modules/os2forms_test_playwright/src/Controller/ExecController.php new file mode 100644 index 0000000..81cca34 --- /dev/null +++ b/modules/os2forms_test_playwright/src/Controller/ExecController.php @@ -0,0 +1,74 @@ + '../vendor/bin/drush', + default => throw new BadRequestHttpException(sprintf('command not found: %s', $command)), + }; + $contentType = $request->getContentTypeFormat(); + $arguments = match ($contentType) { + 'json' => $request->toArray(), + default => throw new BadRequestHttpException(sprintf('invalid content type: %s', $contentType)), + }; + if (empty($arguments)) { + throw new BadRequestHttpException('missing arguments'); + } + + $this->logger->debug('Running command @command @arguments', [ + '@command' => $command, + '@arguments' => $arguments, + ]); + + $response = new StreamedResponse(headers: [ + 'content-type' => 'text/plain', + ]); + $response->setCallback(function () use ($command, $arguments): void { + $process = new Process([$command, ...$arguments]); + $process->mustRun(function ($type, $buffer): void { + echo $buffer; + }); + }); + $response->headers->set('X-Accel-Buffering', 'no'); + $response->send(); + + return $response; + } + catch (\Throwable $throwable) { + $this->logger->error('Error: @message', [ + '@message' => $throwable->getMessage(), + 'throwable' => $throwable, + ]); + throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, message: $throwable->getMessage()); + } + } + +} diff --git a/os2forms_f2.info.yml b/os2forms_f2.info.yml index 78138d6..e007b98 100644 --- a/os2forms_f2.info.yml +++ b/os2forms_f2.info.yml @@ -1,11 +1,13 @@ -name: "os2forms_f2" +name: "F2" type: module description: "OS2Forms: F2 integration" package: OS2Forms core_version_requirement: ^10 || ^11 dependencies: + - drupal:advancedqueue - drupal_psr6_cache:drupal_psr6_cache - os2forms:os2forms + - os2forms_attachment:os2forms_attachment configure: os2forms_f2.admin.settings diff --git a/os2forms_f2.install b/os2forms_f2.install new file mode 100644 index 0000000..2947a3a --- /dev/null +++ b/os2forms_f2.install @@ -0,0 +1,17 @@ +schema(); +} diff --git a/os2forms_f2.links.menu.yml b/os2forms_f2.links.menu.yml index 4b72a0c..74359a3 100644 --- a/os2forms_f2.links.menu.yml +++ b/os2forms_f2.links.menu.yml @@ -1,5 +1,7 @@ os2forms_f2.admin.settings: title: OS2Forms F2 + title_context: "os2forms_f2" description: Configure the OS2Forms F2 module + description_context: "os2forms_f2" parent: system.admin_config_system route_name: os2forms_f2.admin.settings diff --git a/os2forms_f2.routing.yml b/os2forms_f2.routing.yml index 9102eed..2fb5ca0 100644 --- a/os2forms_f2.routing.yml +++ b/os2forms_f2.routing.yml @@ -2,6 +2,7 @@ os2forms_f2.admin.settings: path: "/admin/os2forms_f2/settings" defaults: _title: "F2 settings" + _title_context: "os2forms_f2" _form: 'Drupal\os2forms_f2\Form\SettingsForm' requirements: _permission: "administer site configuration" diff --git a/os2forms_f2.services.yml b/os2forms_f2.services.yml index f4e0bf6..b1e68ef 100644 --- a/os2forms_f2.services.yml +++ b/os2forms_f2.services.yml @@ -14,4 +14,8 @@ services: Drupal\os2forms_f2\Helper\WebformHelperF2: + Drupal\os2forms_f2\Hook\InstallHooks: + Drupal\os2forms_f2\Settings: + + Drupal\os2forms_f2\Repository\F2ItemRepository: diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0cb04bb..2fcf513 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,5 +1,5 @@ - + @@ -10,11 +10,9 @@ vendor rector.php - - web + node_modules - diff --git a/phpstan.neon b/phpstan.neon index 716b6c6..4068c18 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,14 +1,14 @@ parameters: - paths: - - . - level: 5 - customRulesetUsed: true - reportUnmatchedIgnoredErrors: false - excludePaths: - - rector.php - # Ignore any vendor folder (https://phpstan.org/user-guide/ignoring-errors#excluding-whole-files) - - vendor (?) + level: 6 + paths: + - src + ignoreErrors: + - message: '#Constructor of class Drupal\\os2forms_f2\\Exception\\F2NonUniqueCaseException has an unused parameter \$message.#' + path: src/Exception/F2NonUniqueCaseException.php + - message: '#Method Drupal\\os2forms_f2\\.+\(\) return type has no value type specified in iterable type array.#' + - message: '#Method Drupal\\os2forms_f2\\.+\(\) has parameter \$.+ with no value type specified in iterable type array.#' + - message: '#Function .+\(\) return type has no value type specified in iterable type array.#' - ignoreErrors: - - '#Call to method Drupal\\Core\\Entity\\Query\\QueryInterface::accessCheck\(\) will always evaluate to true.#' - - '#Call to method PHPUnit\\Framework\\Assert::assertTrue\(\) with true will always evaluate to true.#' +# Local Variables: +# mode: yaml +# End: diff --git a/rector.php b/rector.php index 44fe530..eaeef35 100644 --- a/rector.php +++ b/rector.php @@ -6,7 +6,7 @@ declare(strict_types=1); -use DrupalRector\Set\Drupal10SetList; +use DrupalRector\Set\Drupal11SetList; use Rector\Config\RectorConfig; return RectorConfig::configure() @@ -15,7 +15,7 @@ // __DIR__ . '/tests', ]) ->withSets([ - Drupal10SetList::DRUPAL_10, + Drupal11SetList::DRUPAL_114, ]) ->withPhpSets(php83: TRUE) ->withTypeCoverageLevel(0); diff --git a/src/Drush/Commands/ApiClientCommand.php b/src/Drush/Commands/ApiClientCommand.php new file mode 100644 index 0000000..a604d93 --- /dev/null +++ b/src/Drush/Commands/ApiClientCommand.php @@ -0,0 +1,74 @@ +addArgument('action', InputArgument::REQUIRED, 'The action') + ->addArgument('arg', InputArgument::OPTIONAL, 'The action argument') + ->addOption('show-links', NULL, InputOption::VALUE_NONE, 'Show links.') + ->addOption('cache-directory', NULL, InputOption::VALUE_REQUIRED, 'Path to the cache directory. If not specified, the default file system cache directory will be used.'); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function getHelp(): string { + return (new F2ApiClientCommand())->getHelp(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $command = new F2ApiClientCommand(); + + $apiSettings = $this->settings->getF2ApiSettings(); + putenv("F2_API_URI={$apiSettings->uri}"); + putenv("F2_API_USERNAME={$apiSettings->username}"); + putenv("F2_API_SECRET={$apiSettings->secret}"); + putenv("F2_F2_USERNAME={$apiSettings->username}"); + + return $command->__invoke( + new SymfonyStyle($input, $output), + $input->getArgument('action'), + $input->getArgument('arg'), + $input->getOption('show-links'), + $input->getOption('cache-directory'), + ); + } + +} diff --git a/src/Exception/F2Exception.php b/src/Exception/F2Exception.php new file mode 100644 index 0000000..09a387a --- /dev/null +++ b/src/Exception/F2Exception.php @@ -0,0 +1,10 @@ +identifier); + parent::__construct($message, $code, $previous); + } + + /** + * Get case identifier. + */ + public function getIdentifier(): string { + return $this->identifier; + } + +} diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index 5de1670..5bbcea3 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -8,7 +8,6 @@ use Drupal\Core\Config\TypedConfigManagerInterface; use Drupal\Core\DependencyInjection\AutowireTrait; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; @@ -17,6 +16,7 @@ use Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\F2ApiSettings; use Drupal\os2forms_f2\Settings\GeneralSettings; +use Psr\Log\LogLevel; /** * Configure F2 settings for this site. @@ -27,25 +27,17 @@ final class SettingsForm extends ConfigFormBase { private const string ACTION_PING_API = 'action_ping_api'; - /** - * The queue storage. - * - * @var \Drupal\Core\Entity\EntityStorageInterface - */ - private readonly EntityStorageInterface $queueStorage; - /** * Constructor. */ public function __construct( ConfigFactoryInterface $config_factory, TypedConfigManagerInterface $typedConfigManager, - EntityTypeManagerInterface $entityTypeManager, - private readonly Settings $settings, - private readonly F2Helper $f2, + protected EntityTypeManagerInterface $entityTypeManager, + protected Settings $settings, + protected F2Helper $f2, ) { parent::__construct($config_factory, $typedConfigManager); - $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); } /** @@ -139,6 +131,23 @@ private function buildFormF2Api(): array { '#description' => $this->t('The F2 username to act on behalf of', options: ['context' => 'os2forms_f2']), ]; + $section[F2ApiSettings::LOG_LEVEL] = [ + '#type' => 'select', + '#options' => [ + LogLevel::EMERGENCY => $this->t('Emergency', options: ['context' => 'os2forms_f2']), + LogLevel::ALERT => $this->t('Alert', options: ['context' => 'os2forms_f2']), + LogLevel::CRITICAL => $this->t('Critical', options: ['context' => 'os2forms_f2']), + LogLevel::ERROR => $this->t('Error', options: ['context' => 'os2forms_f2']), + LogLevel::WARNING => $this->t('Warning', options: ['context' => 'os2forms_f2']), + LogLevel::NOTICE => $this->t('Notice', options: ['context' => 'os2forms_f2']), + LogLevel::INFO => $this->t('Info', options: ['context' => 'os2forms_f2']), + LogLevel::DEBUG => $this->t('Debug', options: ['context' => 'os2forms_f2']), + ], + '#required' => TRUE, + '#title' => $this->t('API log level', options: ['context' => 'os2forms_f2']), + '#default_value' => $settings->logLevel, + ]; + return $section; } @@ -155,13 +164,14 @@ private function buildFormGeneral(): array { '@queue' => $settings->queue, ':queue_url' => '/admin/config/system/queues/jobs/' . urlencode((string) $settings->queue), ], options: ['context' => 'os2forms_f2']); + $storage = $this->entityTypeManager->getStorage('advancedqueue_queue'); $section[GeneralSettings::QUEUE] = [ '#type' => 'select', '#required' => TRUE, '#title' => $this->t('Queue', options: ['context' => 'os2forms_f2']), '#options' => array_map( static fn(EntityInterface $queue) => $queue->label(), - $this->queueStorage->loadMultiple() + $storage->loadMultiple() ), '#empty_option' => $this->t('No queue', options: ['context' => 'os2forms_f2']), '#default_value' => $settings->queue, @@ -194,8 +204,26 @@ public function submitForm(array &$form, FormStateInterface $form_state): void { $this->messenger()->addStatus($this->t('Pinged API successfully.', options: ['context' => 'os2forms_f2'])); } catch (\Throwable $t) { - $this->messenger()->addError($this->t('Pinging API failed: @message', ['@message' => $t->getMessage()], options: ['context' => 'os2forms_f2'])); + $previous = $t->getPrevious(); + $message = NULL !== $previous + ? $this->t( + 'Pinging API failed: @message (@previous_message)', + [ + '@message' => $t->getMessage(), + '@previous_message' => $previous->getMessage(), + ], + options: ['context' => 'os2forms_f2'] + ) + : $this->t( + 'Pinging API failed: @message', + [ + '@message' => $t->getMessage(), + ], + options: ['context' => 'os2forms_f2'] + ); + $this->messenger()->addError($message); } + return; } diff --git a/src/Helper/F2Helper.php b/src/Helper/F2Helper.php index 36ecb32..f9897e5 100644 --- a/src/Helper/F2Helper.php +++ b/src/Helper/F2Helper.php @@ -2,8 +2,13 @@ namespace Drupal\os2forms_f2\Helper; +use Drupal\Core\Logger\LoggerChannelInterface; +use Drupal\os2forms_f2\Exception\F2Exception; +use Drupal\os2forms_f2\Exception\F2NonUniqueCaseException; use Drupal\os2forms_f2\Settings; use ItkDev\F2ApiClient\ApiClient; +use ItkDev\F2ApiClient\Model\CaseFile; +use ItkDev\F2ApiClient\Model\Matter; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -16,6 +21,8 @@ public function __construct( private readonly Settings $settings, #[Autowire(service: 'drupal_psr6_cache.cache_item_pool')] private readonly CacheItemPoolInterface $cacheItemPool, + #[Autowire(service: 'logger.channel.os2forms_f2')] + private readonly LoggerChannelInterface $logger, ) { } @@ -36,7 +43,9 @@ public function client(): ApiClient { 'api_secret' => $settings->secret, 'f2_username' => $settings->f2Username, 'cache_item_pool' => $this->cacheItemPool, + 'log_level' => $settings->logLevel, ]); + $this->apiClient->setLogger($this->logger); } return $this->apiClient; @@ -49,4 +58,51 @@ public function pingApi(): void { $this->client()->matterSearch('ping'); } + /** + * Fetch case by ID or number (by searching and expecting one result). + * + * @throws \Drupal\os2forms_f2\Exception\F2NonUniqueCaseException + * If search does not return a single result. + */ + public function fetchCaseByIdentifier(string $identifier): CaseFile { + $client = $this->client(); + if (filter_var($identifier, FILTER_VALIDATE_INT)) { + return $client->caseById((int) $identifier); + } + + $cases = $client->caseSearch($identifier); + if (1 !== count($cases)) { + throw new F2NonUniqueCaseException($identifier); + } + $case = reset($cases); + + return $client->caseById($this->getId($case->id)); + } + + /** + * Fetch matter by ID or number. + */ + public function fetchMatterByIdentifier(string $identifier): Matter { + $client = $this->client(); + + // @todo Both matter ID and number are integers … + // @code + // if (filter_var($identifier, FILTER_VALIDATE_INT)) { + // return $client->matterById((int) $identifier); + // } + // @endcode + return $client->matterByMatterNumber($identifier); + } + + /** + * Get integer ID from F2 item URL. + */ + private function getId(string $url): int { + if (preg_match('@/cases/(?\d+)$@', $url, $matches)) { + return (int) $matches['id']; + } + + throw new F2Exception('Cannot get ID from ' . $url); + } + } diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php index 036946f..e835004 100644 --- a/src/Helper/WebformHelperF2.php +++ b/src/Helper/WebformHelperF2.php @@ -5,7 +5,6 @@ use Drupal\advancedqueue\Entity\QueueInterface; use Drupal\advancedqueue\Job; use Drupal\advancedqueue\JobResult; -use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Logger\LoggerChannelInterface; @@ -14,17 +13,20 @@ use Drupal\os2forms_f2\Exception\RuntimeException; use Drupal\os2forms_f2\Exception\SubmissionNotFoundException; use Drupal\os2forms_f2\Model\Attachment; +use Drupal\os2forms_f2\Model\F2Item; +use Drupal\os2forms_f2\Model\F2Item\Type; use Drupal\os2forms_f2\Plugin\AdvancedQueue\JobType\F2; use Drupal\os2forms_f2\Plugin\WebformHandler\WebformHandlerF2; +use Drupal\os2forms_f2\Repository\F2ItemRepository; use Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\ArchiveSettings; use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTarget; use Drupal\os2forms_f2\Settings\HandlerSettings; use Drupal\webform\WebformSubmissionInterface; -use Drupal\webform\WebformSubmissionStorageInterface; use Drupal\webform\WebformTokenManagerInterface; use Drupal\webform_attachment\Element\WebformAttachmentBase; use ItkDev\F2ApiClient\Model\Document; +use ItkDev\F2ApiClient\Model\Matter; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -32,46 +34,29 @@ /** * Webform helper. */ -final class WebformHelperF2 implements LoggerInterface { +final readonly class WebformHelperF2 implements LoggerInterface { use LoggerTrait; private const string PDF_MIME_TYPE = 'application/pdf'; - /** - * The webform submission storage. - * - * @var \Drupal\webform\WebformSubmissionStorageInterface - */ - protected WebformSubmissionStorageInterface $webformSubmissionStorage; - - /** - * The queue storage. - * - * @var \Drupal\Core\Entity\EntityStorageInterface - */ - protected EntityStorageInterface $queueStorage; - /** * Constructor. */ public function __construct( - EntityTypeManagerInterface $entityTypeManager, - private readonly Settings $settings, - private readonly F2Helper $f2, - private readonly FileSystemInterface $fileSystem, + private EntityTypeManagerInterface $entityTypeManager, + private Settings $settings, + private F2Helper $f2, + private FileSystemInterface $fileSystem, #[Autowire(service: 'plugin.manager.element_info')] - private readonly ElementInfoManager $elementInfoManager, + private ElementInfoManager $elementInfoManager, #[Autowire(service: 'webform.token_manager')] - private readonly WebformTokenManagerInterface $webformTokenManager, + private WebformTokenManagerInterface $webformTokenManager, + private F2ItemRepository $itemRepository, #[Autowire(service: 'logger.channel.os2forms_f2')] - private readonly LoggerChannelInterface $logger, + private LoggerChannelInterface $logger, #[Autowire(service: 'logger.channel.os2forms_f2_submission')] - private readonly LoggerChannelInterface $submissionLogger, + private LoggerChannelInterface $submissionLogger, ) { - /** @var \Drupal\webform\WebformSubmissionStorageInterface $storage */ - $storage = $entityTypeManager->getStorage('webform_submission'); - $this->webformSubmissionStorage = $storage; - $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); } /** @@ -79,7 +64,7 @@ public function __construct( */ public function loadSubmission(int $id): ?WebformSubmissionInterface { /** @var ?WebformSubmissionInterface $submission */ - $submission = $this->webformSubmissionStorage->load($id); + $submission = $this->entityTypeManager->getStorage('webform_submission')->load($id); return $submission; } @@ -91,7 +76,7 @@ private function loadQueue(): QueueInterface { $id = $this->settings->getGeneralSettings()->queue ?? NULL; /** @var ?\Drupal\advancedqueue\Entity\QueueInterface $queue */ - $queue = $this->queueStorage->load($id); + $queue = $this->entityTypeManager->getStorage('advancedqueue_queue')->load($id); if (NULL === $queue) { throw new RuntimeException(sprintf('Cannot load queue %s', $id)); @@ -150,8 +135,8 @@ public function createJob(WebformSubmissionInterface $webformSubmission, Webform return $job; } catch (\Exception $exception) { - $this->error('Error creating job for F2 archive: %message', $context + [ - '%message' => $exception->getMessage(), + $this->error('Error creating job for F2 archive: @message', $context + [ + '@message' => $exception->getMessage(), 'operation' => 'F2 failed', 'exception' => $exception, ]); @@ -190,7 +175,8 @@ public function processJob(Job $job): JobResult { $target = $handlerSettings->archive?->archiveTarget; return match ($target) { - ArchiveTarget::MatterID => $this->archiveOnMatter($submission, $handlerSettings), + ArchiveTarget::Case => $this->archiveOnCase($submission, $handlerSettings), + ArchiveTarget::Matter => $this->archiveOnMatter($submission, $handlerSettings), default => throw new RuntimeException('Invalid archive target'), }; } @@ -208,31 +194,146 @@ public function processJob(Job $job): JobResult { * Replace tokens in handler settings supporting tokens. */ private function replaceTokens(HandlerSettings $handlerSettings, WebformSubmissionInterface $submission): HandlerSettings { - // @todo Should we clone the settings before making changes? $handlerSettings->archive->documentTitle = $this->webformTokenManager->replace((string) $handlerSettings->archive->documentTitle, $submission); + if ($handlerSettings->archive->archiveTargetCase?->matterTitle) { + $handlerSettings->archive->archiveTargetCase->matterTitle = $this->webformTokenManager->replace($handlerSettings->archive->archiveTargetCase->matterTitle, $submission); + } return $handlerSettings; } + /** + * Archive on case. + */ + private function archiveOnCase(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult { + $context = [ + 'handler_id' => WebformHandlerF2::ID, + 'webform_submission' => $submission, + 'operation' => 'archive on case', + ]; + $caseIdentifier = $handlerSettings->archive?->archiveTargetCase?->caseIdentifier; + if (NULL === $caseIdentifier) { + throw new RuntimeException('Cannot get case identifier'); + } + $case = $this->f2->fetchCaseByIdentifier($caseIdentifier); + + $this->info('Achiving document for submission @submission on case @case ', $context + [ + '@submission' => $submission->label(), + '@case' => (string) $case, + ]); + + $itemType = Type::Matter; + $item = $this->itemRepository->loadOneBySubmissionAndHandlerAndType($submission, $handlerSettings->handlerId, $itemType); + if (NULL !== $item) { + $id = $item->data['id']; + $matter = $this->f2->client()->matterById($id); + $this->info('Matter for submission @submission already exists: @matter', $context + [ + '@submission' => $submission->label(), + '@matter' => (string) $matter, + ]); + } + else { + $this->info('Creating matter for submission @submission', $context + [ + '@submission' => $submission->label(), + ]); + $matter = new Matter(); + $matter->title = $handlerSettings->archive->archiveTargetCase->matterTitle; + $matter->type = $handlerSettings->archive->archiveTargetCase->matterType; + $matter = $this->f2->client()->matterCreate(matter: $matter, case: $case); + + $this->info('Matter for submission @submission created: @matter', $context + [ + '@submission' => $submission->label(), + '@matter' => (string) $matter, + ]); + + $item = new F2Item( + webformId: $submission->getWebform()->id(), + webformHandlerId: $handlerSettings->handlerId, + webformSubmissionId: $submission->id(), + type: $itemType, + data: $matter->toArray(), + ); + } + + $this->itemRepository->save($item); + $message = $this->createDocument($matter, $submission, $handlerSettings); + + return JobResult::success($message); + } + /** * Archive on matter. */ private function archiveOnMatter(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult { - $matterId = $handlerSettings->archive?->archiveTargetMatter?->matterId; - if (NULL === $matterId) { - throw new RuntimeException('Cannot get matter ID'); + $context = [ + 'handler_id' => WebformHandlerF2::ID, + 'webform_submission' => $submission, + 'operation' => 'archive on matter', + ]; + $matterIdentifier = $handlerSettings->archive?->archiveTargetMatter?->matterIdentifier; + if (NULL === $matterIdentifier) { + throw new RuntimeException('Cannot get matter identifier'); } - $matter = $this->f2->client()->matterById($matterId); + $matter = $this->f2->fetchMatterByIdentifier($matterIdentifier); + + $this->info('Achiving document for submission @submission on matter @matter ', $context + [ + '@submission' => $submission->label(), + '@matter' => (string) $matter, + ]); + + $message = $this->createDocument($matter, $submission, $handlerSettings); + + return JobResult::success($message); + } + + /** + * Create document in F2. + */ + private function createDocument(Matter $matter, WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): string { + $context = [ + 'handler_id' => WebformHandlerF2::ID, + 'webform_submission' => $submission, + 'operation' => 'create document', + ]; $attachment = $this->getAttachment($submission, $handlerSettings); - $message = ''; try { - // Apparently the F2 API inspects the filename extension to determine file - // type, i.e. we must keep the extension in the temporary filename. - $filePath = $this->fileSystem->saveData($attachment->contents, 'temporary://' . uniqid('os2forms_f2') . '-' . $attachment->filename); - $document = new Document(); - $document->title = $handlerSettings->archive?->documentTitle ?? $attachment->filename; - $document = $this->f2->client()->documentCreate($document, $filePath, $matter); - $message = sprintf('Document %s created on matter %s', $document, $matter); + $itemType = Type::Document; + $item = $this->itemRepository->loadOneBySubmissionAndHandlerAndType($submission, $handlerSettings->handlerId, $itemType); + if (NULL !== $item) { + // Document already exists. What to do? + $id = $item->data['id']; + $document = $this->f2->client()->documentById($id); + $this->info('Document on matter @matter for submission @submission already exists: @document', $context + [ + '@submission' => $submission->label(), + '@matter' => (string) $matter, + '@document' => (string) $document, + ]); + $message = sprintf('Document %s already exists on matter %s', $document, $matter); + } + else { + // Apparently the F2 API inspects the filename extension to determine + // file type, i.e. we must keep the extension in the temporary filename. + $filePath = $this->fileSystem->saveData($attachment->contents, 'temporary://' . uniqid('os2forms_f2') . '-' . $attachment->filename); + $document = new Document(); + $document->title = $handlerSettings->archive->documentTitle ?? $attachment->filename; + $document = $this->f2->client()->documentCreate($document, $filePath, $matter); + $this->info('Document on matter @matter for submission @submission created: @document', $context + [ + '@submission' => $submission->label(), + '@matter' => (string) $matter, + '@document' => (string) $document, + ]); + $message = sprintf('Document %s created on matter %s', $document, $matter); + + $item = new F2Item( + webformId: $submission->getWebform()->id(), + webformHandlerId: $handlerSettings->handlerId, + webformSubmissionId: $submission->id(), + type: $itemType, + data: $document->toArray(), + ); + } + + $this->itemRepository->save($item); } finally { if (isset($filePath) && file_exists($filePath)) { @@ -240,7 +341,7 @@ private function archiveOnMatter(WebformSubmissionInterface $submission, Handler } } - return JobResult::success($message); + return $message; } /** diff --git a/src/Hook/InstallHooks.php b/src/Hook/InstallHooks.php new file mode 100644 index 0000000..60dd0f4 --- /dev/null +++ b/src/Hook/InstallHooks.php @@ -0,0 +1,80 @@ + 'Stores data on F2 items created by the os2forms_f2 module.', + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'Unique ID.', + ], + 'webform_id' => [ + 'description' => 'Webform ID.', + 'type' => 'varchar', + 'length' => '256', + 'not null' => TRUE, + ], + 'webform_handler_id' => [ + 'description' => 'Webform handler ID.', + 'type' => 'varchar', + 'length' => '256', + 'not null' => TRUE, + ], + 'webform_submission_id' => [ + 'description' => 'Webform submission ID. References {webform_submissions}.sid.', + 'type' => 'int', + 'size' => 'big', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'type' => [ + 'description' => 'F2 item type ("case", "matter" or "document").', + 'type' => 'varchar', + 'length' => '256', + 'not null' => TRUE, + ], + 'data' => [ + 'description' => 'The data (JSON serialized).', + 'type' => 'text', + 'size' => 'big', + 'not null' => TRUE, + ], + 'created_at' => [ + 'description' => 'The Unix timestamp when the item was created.', + 'type' => 'int', + 'size' => 'big', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'updated_at' => [ + 'description' => 'The Unix timestamp when the item was updated.', + 'type' => 'int', + 'size' => 'big', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + ], + 'primary key' => [ + 'id', + ], + 'indexes' => [ + 'webform_submission' => ['webform_id', 'webform_handler_id', 'webform_submission_id'], + ], + ]; + + return $schema; + } + +} diff --git a/src/Model/F2Item.php b/src/Model/F2Item.php new file mode 100644 index 0000000..b46a7bc --- /dev/null +++ b/src/Model/F2Item.php @@ -0,0 +1,26 @@ + $configuration - */ - #[\Override] - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get(WebformHelperF2::class) - ); - } - /** * {@inheritdoc} * @@ -48,11 +33,26 @@ public function __construct( /** * The webform helper. */ - private readonly WebformHelperF2 $helper, + protected WebformHelperF2 $helper, ) { parent::__construct($configuration, $plugin_id, $plugin_definition); } + /** + * {@inheritdoc} + * + * @phpstan-param array $configuration + */ + #[\Override] + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get(WebformHelperF2::class) + ); + } + /** * {@inheritdoc} */ diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php index a315950..4243595 100644 --- a/src/Plugin/WebformHandler/WebformHandlerF2.php +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -5,16 +5,22 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\os2forms_f2\Exception\F2NonUniqueCaseException; use Drupal\os2forms_f2\Helper\F2Helper; use Drupal\os2forms_f2\Helper\WebformHelperF2; use Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\ArchiveSettings; use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTarget; +use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTargetCase; use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTargetMatter; use Drupal\webform\Plugin\WebformHandlerBase; use Drupal\webform\Utility\WebformDialogHelper; use Drupal\webform\WebformSubmissionInterface; +use ItkDev\F2ApiClient\Model\Matter; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; /** * F2 Webform Handler. @@ -44,17 +50,22 @@ final class WebformHandlerF2 extends WebformHandlerBase { /** * The settings. */ - private Settings $settingsService; + protected Settings $settingsService; /** * The webform helper. */ - private WebformHelperF2 $helper; + protected WebformHelperF2 $helper; /** * The F2 helper. */ - private F2Helper $f2; + protected F2Helper $f2; + + /** + * The request stack. + */ + protected RequestStack $requestStack; /** * {@inheritdoc} @@ -65,6 +76,9 @@ public static function create(ContainerInterface $container, array $configuratio $instance->settingsService = $container->get(Settings::class); $instance->helper = $container->get(WebformHelperF2::class); $instance->f2 = $container->get(F2Helper::class); + /** @var RequestStack $requestStack */ + $requestStack = $container->get('request_stack'); + $instance->requestStack = $requestStack; return $instance; } @@ -91,7 +105,7 @@ public function getOffCanvasWidth(): string { * {@inheritdoc} */ #[\Override] - public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { $settings = $this->settingsService->getArchiveSettings((array) ($this->getSetting(ArchiveSettings::NAME))); // We have to wrap all handler setting fields in a container to show them on @@ -112,8 +126,8 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#type' => 'textfield', '#required' => TRUE, '#title' => $this->t('Document title', options: ['context' => 'os2forms_f2']), - '#default_value' => $settings->documentTitle, '#description' => $this->t('The title of the document. Tokens can be used in the title, e.g. [webform_submission:label].', options: ['context' => 'os2forms_f2']), + '#default_value' => $settings->documentTitle, ], ArchiveSettings::ARCHIVE_TARGET => [ @@ -122,25 +136,119 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#title' => $this->t('Archive target', options: ['context' => 'os2forms_f2']), '#default_value' => $settings->archiveTarget?->value, '#options' => [ - ArchiveTarget::MatterID->value => $this->t('Matter ID', options: ['context' => 'os2forms_f2']), + ArchiveTarget::Case->value => $this->t('Case', options: ['context' => 'os2forms_f2']), + ArchiveTarget::Matter->value => $this->t('Matter', options: ['context' => 'os2forms_f2']), ], ], + ArchiveTargetCase::NAME => [ + '#type' => 'fieldset', + '#title' => $this->t('Case', options: ['context' => 'os2forms_f2']), + '#states' => [ + 'visible' => [ + ':input[name="settings[' . implode('][', [ + self::ID, + ArchiveSettings::NAME, + ArchiveSettings::ARCHIVE_TARGET, + ]) . ']"]' => [ + 'value' => ArchiveTarget::Case->value, + ], + ], + ], + + ArchiveTargetCase::CASE_IDENTIFIER => [ + '#type' => 'textfield', + '#title' => $this->t('Case identifier', options: ['context' => 'os2forms_f2']), + '#description' => $this->t('Case number or ID that uniquely identifies a single case.', options: ['context' => 'os2forms_f2']), + '#default_value' => $settings->archiveTargetCase?->caseIdentifier, + + '#states' => [ + 'required' => [ + ':input[name="settings[' . implode('][', [ + self::ID, + ArchiveSettings::NAME, + ArchiveSettings::ARCHIVE_TARGET, + ]) . ']"]' => [ + 'value' => ArchiveTarget::Case->value, + ], + ], + ], + ], + + ArchiveTargetCase::MATTER_TITLE => [ + '#type' => 'textfield', + '#title' => $this->t('Matter title', options: ['context' => 'os2forms_f2']), + '#description' => $this->t('The title of the matter that will be created for the document. Tokens can be used in the title, e.g. [webform_submission:label].', options: ['context' => 'os2forms_f2']), + '#default_value' => $settings->archiveTargetCase?->matterTitle, + + '#states' => [ + 'required' => [ + ':input[name="settings[' . implode('][', [ + self::ID, + ArchiveSettings::NAME, + ArchiveSettings::ARCHIVE_TARGET, + ]) . ']"]' => [ + 'value' => ArchiveTarget::Case->value, + ], + ], + ], + ], + + ArchiveTargetCase::MATTER_TYPE => [ + '#type' => 'select', + '#options' => [ + Matter::TYPE_INBOUND => $this->t('Inbound', options: ['context' => 'os2forms_f2']), + Matter::TYPE_INTERNAL => $this->t('Internal', options: ['context' => 'os2forms_f2']), + Matter::TYPE_OUTBOUND => $this->t('Outbound', options: ['context' => 'os2forms_f2']), + ], + '#title' => $this->t('Matter type', options: ['context' => 'os2forms_f2']), + '#description' => $this->t('The type of the matter that will be created for the document.', options: ['context' => 'os2forms_f2']), + '#default_value' => $settings->archiveTargetCase?->matterType, + + '#states' => [ + 'required' => [ + ':input[name="settings[' . implode('][', [ + self::ID, + ArchiveSettings::NAME, + ArchiveSettings::ARCHIVE_TARGET, + ]) . ']"]' => [ + 'value' => ArchiveTarget::Case->value, + ], + ], + ], + ], + + ], + ArchiveTargetMatter::NAME => [ - ArchiveTargetMatter::MATTER_ID => [ + '#type' => 'fieldset', + '#title' => $this->t('Matter', options: ['context' => 'os2forms_f2']), + '#states' => [ + 'visible' => [ + ':input[name="settings[' . implode('][', [ + self::ID, + ArchiveSettings::NAME, + ArchiveSettings::ARCHIVE_TARGET, + ]) . ']"]' => [ + 'value' => ArchiveTarget::Matter->value, + ], + ], + ], + + ArchiveTargetMatter::MATTER_IDENTIFIER => [ '#type' => 'textfield', - '#required' => TRUE, - '#title' => $this->t('Matter ID', options: ['context' => 'os2forms_f2']), - '#default_value' => $settings->archiveTargetMatter?->matterId, + '#title' => $this->t('Matter identifier', options: ['context' => 'os2forms_f2']), + '#description' => $this->t('Matter number or ID that uniquely identifies a single matter.', options: ['context' => 'os2forms_f2']), + '#default_value' => $settings->archiveTargetMatter?->matterIdentifier, '#states' => [ - 'visible' => [ + 'required' => [ ':input[name="settings[' . implode('][', [ self::ID, ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET, ]) . ']"]' => [ - 'value' => ArchiveTarget::MatterID->value, + 'value' => ArchiveTarget::Matter->value, ], ], ], @@ -149,22 +257,58 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta ], ]; - if (ArchiveTarget::MatterID === $settings->archiveTarget) { - $matterId = $settings->archiveTargetMatter?->matterId; - if (NULL !== $matterId) { - try { - $matter = $this->f2->client()->matterById($matterId); - - $form[self::ID][ArchiveSettings::NAME][ArchiveTargetMatter::NAME]['details'] = [ - '#type' => 'details', - '#open' => TRUE, - '#title' => $this->t('Matter', options: ['context' => 'os2forms_f2']), - '#markup' => $matter, - ]; - } - catch (\Throwable) { - // Ignore all errors. - } + // Show some details in the archive target. + if (Request::METHOD_GET === $this->requestStack->getMainRequest()->getRealMethod()) { + switch ($settings->archiveTarget) { + case ArchiveTarget::Case: + $caseIdentifier = $settings->archiveTargetCase?->caseIdentifier; + if (NULL !== $caseIdentifier) { + try { + $case = $this->f2->fetchCaseByIdentifier($caseIdentifier); + + $details = [ + '#markup' => (string) $case, + ]; + } + catch (\Throwable $t) { + $details = [ + '#markup' => $this->t('Error: @error', ['@error' => $t->getMessage()], options: ['context' => 'os2forms_f2']), + ]; + } + + $form[self::ID][ArchiveSettings::NAME][ArchiveTargetCase::NAME]['details'] = [ + '#type' => 'details', + '#attributes' => ['id' => 'archive-details'], + '#open' => TRUE, + '#title' => $this->t('Case details', options: ['context' => 'os2forms_f2']), + ] + $details; + } + break; + + case ArchiveTarget::Matter: + $matterIdentifier = $settings->archiveTargetMatter?->matterIdentifier; + if (NULL !== $matterIdentifier) { + try { + $matter = $this->f2->fetchMatterByIdentifier($matterIdentifier); + $details = [ + '#markup' => (string) $matter, + ]; + } + catch (\Throwable $t) { + $details = [ + '#markup' => $this->t('Error: @error', ['@error' => $t->getMessage()], options: ['context' => 'os2forms_f2']), + ]; + } + + $form[self::ID][ArchiveSettings::NAME][ArchiveTargetMatter::NAME]['details'] = [ + '#type' => 'details', + '#attributes' => ['id' => 'archive-details'], + '#open' => TRUE, + '#title' => $this->t('Matter details', options: ['context' => 'os2forms_f2']), + ] + $details; + } + break; + } } @@ -177,39 +321,70 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta * {@inheritdoc} */ #[\Override] - public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void { parent::validateConfigurationForm($form, $form_state); $setError = static fn(string|array $path, TranslatableMarkup $message) => $form_state->setErrorByName(implode('][', (array) $path), $message); - $target = $form_state->getValue([self::ID, ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET]); - if (ArchiveTarget::MatterID->value === $target) { - $key = [self::ID, ArchiveSettings::NAME, ArchiveTargetMatter::NAME, ArchiveTargetMatter::MATTER_ID]; - $matterId = trim((string) $form_state->getValue($key)); - $matterId = filter_var($matterId, FILTER_VALIDATE_INT); - if (FALSE === $matterId) { - $setError($key, t('Missing or invalid matter ID.', options: ['context' => 'os2forms_f2'])); - } - else { - try { - $this->f2->client()->matterById($matterId); - } - catch (\Throwable $throwable) { - $setError($key, t('Cannot get matter by ID @matter_id (@message).', [ - '@matter_id' => $matterId, - '@message' => $throwable->getMessage(), - ], options: ['context' => 'os2forms_f2'])); - } + $targetKey = [self::ID, ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET]; + $target = $form_state->getValue($targetKey); + try { + $target = ArchiveTarget::from($target); + switch ($target) { + case ArchiveTarget::Case: + $key = [self::ID, ArchiveSettings::NAME, ArchiveTargetCase::NAME, ArchiveTargetCase::CASE_IDENTIFIER]; + $caseIdentifier = trim((string) $form_state->getValue($key)); + if ('' === $caseIdentifier) { + $setError($key, t('Missing case identifier.', options: ['context' => 'os2forms_f2'])); + } + else { + try { + $this->f2->fetchCaseByIdentifier($caseIdentifier); + } + catch (F2NonUniqueCaseException) { + $setError($key, t('Identifier "@case_identifier" does not uniquely identify a case.', [ + '@case_identifier' => $caseIdentifier, + ], options: ['context' => 'os2forms_f2'])); + } + catch (\Throwable $throwable) { + $setError($key, t('Cannot get case by identifier "@case_identifier" (@message).', [ + '@case_identifier' => $caseIdentifier, + '@message' => $throwable->getMessage(), + ], options: ['context' => 'os2forms_f2'])); + } + } + break; + + case ArchiveTarget::Matter: + $key = [self::ID, ArchiveSettings::NAME, ArchiveTargetMatter::NAME, ArchiveTargetMatter::MATTER_IDENTIFIER]; + $matterIdentifier = trim((string) $form_state->getValue($key)); + if ('' === $matterIdentifier) { + $setError($key, t('Missing matter identifier.', options: ['context' => 'os2forms_f2'])); + } + else { + try { + $this->f2->fetchMatterByIdentifier($matterIdentifier); + } + catch (\Throwable $throwable) { + $setError($key, t('Cannot get matter by identifier "@matter_identifier" (@message).', [ + '@matter_identifier' => $matterIdentifier, + '@message' => $throwable->getMessage(), + ], options: ['context' => 'os2forms_f2'])); + } + } + break; } } - + catch (\ValueError) { + $setError($targetKey, $this->t('Invalid archive target: @target', ['@target' => $target], options: ['context' => 'os2forms_f2'])); + } } /** * {@inheritdoc} */ #[\Override] - public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { parent::submitConfigurationForm($form, $form_state); foreach ([ ArchiveSettings::NAME, @@ -224,7 +399,7 @@ public function submitConfigurationForm(array &$form, FormStateInterface $form_s * {@inheritdoc} */ #[\Override] - public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE) { + public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE): void { if (!$this->additionalStatesRunOnPostSave($webform_submission)) { return; } @@ -236,27 +411,48 @@ public function postSave(WebformSubmissionInterface $webform_submission, $update * {@inheritdoc} */ #[\Override] - public function getSummary() { + public function getSummary(): array { $settings = $this->settingsService->getHandlerSettings($this); - $build = [ - 'info' => [ - '#prefix' => '
', - '#suffix' => '
', - ], - ]; + $summary = []; switch ($settings->archive?->archiveTarget) { - case ArchiveTarget::MatterID: - $matterId = $settings->archive->archiveTargetMatter?->matterId; - if ($matterId) { - $build['info'][ArchiveTargetMatter::NAME] = [ - '#markup' => $this->t('Archive on matter @matter_id', ['@matter_id' => $matterId], options: ['context' => 'os2forms_f2']), + case ArchiveTarget::Case: + $caseIdentifier = $settings->archive->archiveTargetCase?->caseIdentifier; + if ($caseIdentifier) { + $summary[ArchiveTargetCase::NAME] = [ + '#markup' => $this->t('Archive on case "@case_identifier".', ['@case_identifier' => $caseIdentifier], options: ['context' => 'os2forms_f2']), + ]; + } + break; + + case ArchiveTarget::Matter: + $matterIdentifier = $settings->archive->archiveTargetMatter?->matterIdentifier; + if ($matterIdentifier) { + $summary[ArchiveTargetMatter::NAME] = [ + '#markup' => $this->t('Archive on matter "@matter_identifier".', ['@matter_identifier' => $matterIdentifier], options: ['context' => 'os2forms_f2']), ]; } break; } + $build = []; + if ($summary) { + $build['summary'] = [ + '#type' => 'container', + ] + $summary; + $build['see_details'] = [ + '#type' => 'container', + '#markup' => ' ' . $this->t('Edit to see details.', [ + ':edit_url' => Url::fromRoute('entity.webform.handler.edit_form', [ + 'webform' => $this->getWebform()->id(), + 'webform_handler' => $this->getHandlerId(), + ])->toString(TRUE)->getGeneratedUrl(), + ], options: ['context' => 'os2forms_f2']), + '#weight' => 999, + ]; + } + return $build; } diff --git a/src/Repository/AbstractRepository.php b/src/Repository/AbstractRepository.php new file mode 100644 index 0000000..29ef5cc --- /dev/null +++ b/src/Repository/AbstractRepository.php @@ -0,0 +1,23 @@ + $orderBy + * The order. + * + * @return \Drupal\os2forms_f2\Model\F2Item[] + * The list of items + */ + private function loadBy(array $conditions = [], array $orderBy = ['created_at' => 'DESC']): array { + $query = $this->database + ->select(self::TABLE, 't') + ->fields('t'); + + foreach ($conditions as $condition) { + $query->condition(...$condition); + } + + foreach ($orderBy as $field => $direction) { + $query->orderBy($field, $direction); + } + + $statement = $query->execute(); + assert(NULL !== $statement); + $result = $statement->fetchAll(); + + return array_map( + fn(object $row) => new F2Item( + webformId: $row->webform_id, + webformHandlerId: $row->webform_handler_id, + webformSubmissionId: $row->webform_submission_id, + type: Type::from($row->type), + data: $this->unserializeData($row->data), + createdAt: $row->created_at, + updatedAt: $row->updated_at, + ), + array: $result + ); + } + + /** + * Load one item by ... + */ + public function loadOneBySubmissionAndHandlerAndType(WebformSubmissionInterface $submission, string $handlerId, Type $type): ?F2Item { + $items = $this->loadBy([ + ['webform_id', $submission->getWebform()->id()], + ['webform_handler_id', $handlerId], + ['webform_submission_id', $submission->id()], + ['type', $type->value], + ]); + + return 1 === count($items) ? reset($items) : NULL; + } + + /** + * Load items for a submission. + * + * @return \Drupal\os2forms_f2\Model\F2Item[] + * The result. + */ + public function loadBySubmission(WebformSubmissionInterface $submission): array { + return $this->loadBy([ + ['webform_submission_id', $submission->id()], + ]); + } + + /** + * Delete by submission. + * + * @param \Drupal\webform\WebformSubmissionInterface[] $submissions + * The submissions. + */ + public function deleteBySubmissions(array $submissions): int { + if (empty($submissions)) { + return 0; + } + + try { + $ids = array_map(static fn(WebformSubmissionInterface $submission) => $submission->id(), $submissions); + return $this->database->delete(self::TABLE) + ->condition('webform_submission_id', $ids, 'IN') + ->execute(); + } + catch (\Exception $exception) { + $this->logger->error('Error deleting F2 items for submissions @submissions: @message', [ + '@submissions' => implode(', ', $ids ?? []), + '@message' => $exception->getMessage(), + 'exception' => $exception, + ]); + } + + return 0; + } + + /** + * Save F2 item. + */ + public function save(F2Item $item): bool { + try { + $now = $this->time->getRequestTime(); + $item->createdAt ??= $now; + $item->updatedAt = $now; + + $fields = [ + 'webform_id' => $item->webformId, + 'webform_handler_id' => $item->webformHandlerId, + 'webform_submission_id' => $item->webformSubmissionId, + 'type' => $item->type->value, + 'data' => $this->serializeData($item->data), + 'created_at' => $item->createdAt, + 'updated_at' => $item->updatedAt, + ]; + + if (0 === count($this->loadBy([ + ['webform_id', $fields['webform_id']], + ['webform_handler_id', $fields['webform_handler_id']], + ['webform_submission_id', $fields['webform_submission_id']], + ['type', $fields['type']], + ]))) { + $this->database + ->insert(self::TABLE) + ->fields($fields) + ->execute(); + } + else { + $this->database + ->update(self::TABLE) + ->condition('webform_id', $fields['webform_id']) + ->condition('webform_handler_id', $fields['webform_handler_id']) + ->condition('webform_submission_id', $fields['webform_submission_id']) + ->condition('type', $fields['type']) + ->fields($fields) + ->execute(); + } + + return TRUE; + } + catch (\Exception $exception) { + $this->logger->error('Error saving F2 item: @message', [ + '@message' => $exception->getMessage(), + 'exception' => $exception, + ]); + } + + return FALSE; + } + + /** + * Serialize data. + */ + private function serializeData(array $data): string { + return json_encode($data, JSON_THROW_ON_ERROR); + } + + /** + * Unserialize data. + */ + private function unserializeData(string $data): array { + return (array) json_decode($data, TRUE); + } + +} diff --git a/src/Settings/ArchiveSettings.php b/src/Settings/ArchiveSettings.php index 68cf7cb..1ce3bc0 100644 --- a/src/Settings/ArchiveSettings.php +++ b/src/Settings/ArchiveSettings.php @@ -3,6 +3,7 @@ namespace Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTarget; +use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTargetCase; use Drupal\os2forms_f2\Settings\ArchiveSettings\ArchiveTargetMatter; /** @@ -16,6 +17,7 @@ final class ArchiveSettings extends AbstractSettings { ]; protected static array $settingsProperties = [ + self::ARCHIVE_TARGET_CASE => ArchiveTargetCase::class, self::ARCHIVE_TARGET_MATTER => ArchiveTargetMatter::class, ]; @@ -31,6 +33,9 @@ final class ArchiveSettings extends AbstractSettings { const string ARCHIVE_TARGET = 'archive_target'; public ?ArchiveTarget $archiveTarget = NULL; + const string ARCHIVE_TARGET_CASE = 'archive_target_case'; + public ?ArchiveTargetCase $archiveTargetCase = NULL; + const string ARCHIVE_TARGET_MATTER = 'archive_target_matter'; public ?ArchiveTargetMatter $archiveTargetMatter = NULL; diff --git a/src/Settings/ArchiveSettings/ArchiveTarget.php b/src/Settings/ArchiveSettings/ArchiveTarget.php index 374013e..3161024 100644 --- a/src/Settings/ArchiveSettings/ArchiveTarget.php +++ b/src/Settings/ArchiveSettings/ArchiveTarget.php @@ -6,5 +6,6 @@ * Archive targets. */ enum ArchiveTarget: string { - case MatterID = 'matter_id'; + case Case = 'case'; + case Matter = 'matter'; } diff --git a/src/Settings/ArchiveSettings/ArchiveTargetCase.php b/src/Settings/ArchiveSettings/ArchiveTargetCase.php new file mode 100644 index 0000000..82be5d0 --- /dev/null +++ b/src/Settings/ArchiveSettings/ArchiveTargetCase.php @@ -0,0 +1,24 @@ +=20" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/dotenv": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz", + "integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/Playwright/package.json b/tests/Playwright/package.json new file mode 100644 index 0000000..426f520 --- /dev/null +++ b/tests/Playwright/package.json @@ -0,0 +1,16 @@ +{ + "name": "playwright", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "@playwright/test": "^1.64.1", + "@types/node": "^26.6.5", + "dotenv": "^18.0.9" + } +} diff --git a/tests/Playwright/playwright.config.ts b/tests/Playwright/playwright.config.ts new file mode 100644 index 0000000..bafccd1 --- /dev/null +++ b/tests/Playwright/playwright.config.ts @@ -0,0 +1,90 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import dotenv from "dotenv"; +import path from "path"; +dotenv.config({ path: path.resolve(__dirname, ".env") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [[process.env.CI ? "github" : "list"], ["html", { open: "never" }]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: process.env.BASEURL, + ignoreHTTPSErrors: true, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + screenshot: "only-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + // https://playwright.dev/docs/auth#basic-shared-account-in-all-tests + { name: "setup", testMatch: /.*\.setup\.ts/ }, + + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + // Use prepared auth state. + storageState: "playwright/.auth/user.json", + }, + dependencies: ["setup"], + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests/Playwright/tests/auth.setup.ts b/tests/Playwright/tests/auth.setup.ts new file mode 100644 index 0000000..f3cacbc --- /dev/null +++ b/tests/Playwright/tests/auth.setup.ts @@ -0,0 +1,18 @@ +// https://playwright.dev/docs/auth#basic-shared-account-in-all-tests +import { test as setup, expect } from "@playwright/test"; +import path from "path"; + +const authFile = path.join(__dirname, "../playwright/.auth/user.json"); + +setup("authenticate", async ({ page }) => { + // Perform authentication steps. Replace these actions with your own. + await page.goto("/user/login"); + await page.locator('[name="name"]').fill("admin"); + await page.locator('[name="pass"]').fill("password"); + await page.locator('[name="pass"]').press("Enter"); + + await page.goto("/admin"); + // End of authentication steps. + + await page.context().storageState({ path: authFile }); +}); diff --git a/tests/Playwright/tests/user-manual-images.spec.ts b/tests/Playwright/tests/user-manual-images.spec.ts new file mode 100644 index 0000000..aa428f5 --- /dev/null +++ b/tests/Playwright/tests/user-manual-images.spec.ts @@ -0,0 +1,351 @@ +import { test, expect } from "@playwright/test"; + +// We want run and complete the administrator stuff before running the form-builder stuff. +// https://playwright.dev/docs/test-parallel +test.describe.configure({ mode: "serial" }); + +import dotenv from "dotenv"; +import path from "path"; +dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); + +const submitSelector = 'input[data-drupal-selector="edit-submit"]'; + +test("user manual images – administrator (da)", async ({ page }) => { + const locale = "da"; + const context = "administrator"; + let screenshotCounter = 0; + + const screenshot = async (name: string, selector: string = "main") => { + const filePath = path.join( + `/docs/user-manual/${locale}/images/generated/`, + context, + // String(screenshotCounter++).padStart(3, "0") + "-" + + name + ".png", + ); + console.debug(filePath); + await page.locator(selector).screenshot({ path: filePath }); + }; + + const drush = async (args: string[]) => { + return page.request.post("/os2forms-test-playwright/exec/drush", { + data: args, + timeout: 4000, + }); + }; + + const goto = async (url: string) => { + url = `/${locale}/${url}`; + console.debug(`going to ${url} …`); + return page.goto(url); + }; + + const redact = async (selector: string, length: int = 8) => { + return page.evaluate( + ({ selector, length }) => { + const element = document.querySelector(selector); + element.value = "•".repeat(length); + + // https://css-tricks.com/fun-with-blurred-text/ + // element.style.color = 'transparent'; + // element.style.textShadow ='0 0 5px rgba(0,0,0,0.5)'; + }, + { selector, length }, + ); + }; + + await goto("admin/os2forms_f2/settings"); + await page.locator('[name="f2_api[uri]"]').fill(""); + await page.locator('[name="f2_api[username]"]').fill(""); + await page.locator('[name="f2_api[secret]"]').fill(""); + await page.locator('[name="f2_api[f2_username]"]').fill(""); + await page.locator('[name="general[queue]"]').selectOption(null); + await page.locator('[name="f2_api[uri]"]').focus(); + await page.locator('[name="f2_api[uri]"]').blur(); + await screenshot("f2-settings-empty", ".page-wrapper"); + + await goto("admin/os2forms_f2/settings"); + await page.locator('[name="f2_api[uri]"]').fill("https://f2.example.com"); + await page.locator('[name="f2_api[username]"]').fill("api-user"); + await page.locator('[name="f2_api[secret]"]').fill("api-$ecr3t"); + await page.locator('[name="f2_api[f2_username]"]').fill("f2-user"); + await page.locator('[name="general[queue]"]').selectOption("os2forms_f2"); + await page.locator('[name="f2_api[uri]"]').focus(); + await page.locator('[name="f2_api[uri]"]').blur(); + + await redact('[name="f2_api[username]"]', 16); + await redact('[name="f2_api[secret]"]', 16); + + await screenshot("f2-settings-filled"); + return; + + await page.locator(submitSelector).click(); + await screenshot("f2-settings-saved"); + + await page + .locator('input[data-drupal-selector="edit-action-ping-api"]') + .click(); + await screenshot("f2-settings-api-pinged-error", ".messages__wrapper"); + + // Refill with proper values + await page.locator('[name="f2_api[uri]"]').fill(process.env.F2_API_URI); + await page + .locator('[name="f2_api[username]"]') + .fill(process.env.F2_API_USERNAME); + await page.locator('[name="f2_api[secret]"]').fill(process.env.F2_API_SECRET); + await page + .locator('[name="f2_api[f2_username]"]') + .fill(process.env.F2_API_F2_USERNAME); + await page.locator('[name="f2_api[uri]"]').focus(); + await page.locator('[name="f2_api[uri]"]').blur(); + await page.locator(submitSelector).click(); + + await page + .locator('input[data-drupal-selector="edit-action-ping-api"]') + .click(); + await screenshot("f2-settings-api-pinged-success", ".messages__wrapper"); +}); + +test("user manual images – form-builder (da)", async ({ page }) => { + const locale = "da"; + const context = "form-builder"; + let screenshotCounter = 0; + + const goto = async (url: string) => { + url = `/${locale}/${url}`; + console.debug(` +going to ${url} …`); + return page.goto(url); + }; + + const screenshot = async (name: string, selector: string = "main") => { + const filePath = path.join( + `/docs/user-manual/${locale}/images/generated/`, + context, + // String(screenshotCounter++).padStart(3, "0") + "-" + + name + ".png", + ); + const pagePath = page.url().replace(process.env.BASEURL, ""); + console.debug(` +screenshooting ${selector} + on ${pagePath} + into ${filePath}`); + await page.locator(selector).screenshot({ path: filePath }); + }; + + const drush = async (args: string[]) => { + return page.request.post("/os2forms-test-playwright/exec/drush", { + data: args, + timeout: 4000, + }); + }; + + // Add handler + await goto("admin/structure/webform/manage/os2forms_f2_ex_000/handlers"); + await page + .locator( + '[data-drupal-link-system-path="admin/structure/webform/manage/os2forms_f2_ex_000/handlers/add"]', + ) + .click(); + await page.locator(".form-element--type-search").fill("F2"); + // Wait for the AJAX search + await page.waitForTimeout(1000); + await screenshot("webform-handler-select-f2", ".webform-ui-dialog"); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_000/handlers/add/os2forms_f2_f2", + ); + await screenshot("webform-handler-add-general", "#webform-handler-form"); + await screenshot( + "webform-handler-add-general-archive-settings", + "#edit-settings-os2forms-f2-f2", + ); + + // Archive on case + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_000/handlers/add/os2forms_f2_f2", + ); + + await page.locator('[name="label"]').fill("F2 – arkivér på sag"); + // Unhide and fill machine name field. + await page.evaluate( + 'document.querySelector(".form-type-machine-name").classList.remove("hidden")', + ); + await page.locator('[name="handler_id"]').fill("f2_arkiver_paa_sag"); + await page + .locator('[name="settings[os2forms_f2_f2][archive][attachment_element]"]') + .selectOption("attachment"); + await page + .locator('[name="settings[os2forms_f2_f2][archive][document_title]"]') + .fill("[webform_submission:label] ([site:url])"); + await page + .locator('[name="settings[os2forms_f2_f2][archive][archive_target]"]') + .focus(); + + await screenshot( + "webform-handler-add-general-archive-settings-filled", + "#edit-settings-os2forms-f2-f2", + ); + + await page + .locator('[name="settings[os2forms_f2_f2][archive][archive_target]"]') + .selectOption("case"); + await screenshot( + "webform-handler-add-archive-on-case", + "#edit-settings-os2forms-f2-f2-archive-archive-target-case", + ); + + await page + .locator( + '[name="settings[os2forms_f2_f2][archive][archive_target_case][case_identifier]"]', + ) + .fill(process.env.F2_CASE_IDENTIFIER_INVALID); + await page + .locator( + '[name="settings[os2forms_f2_f2][archive][archive_target_case][matter_title]"]', + ) + .fill("[webform_submission:label]"); + await page + .locator( + '[name="settings[os2forms_f2_f2][archive][archive_target_case][matter_type]"]', + ) + .selectOption("Internal"); + await screenshot( + "webform-handler-add-archive-on-case-filled", + "#edit-settings-os2forms-f2-f2-archive-archive-target-case", + ); + + await page.locator(submitSelector).click(); + await screenshot( + "webform-handler-add-archive-on-case-filled-invalid-case-identifier", + ".messages__wrapper", + ); + + await page + .locator( + '[name="settings[os2forms_f2_f2][archive][archive_target_case][case_identifier]"]', + ) + .fill(process.env.F2_CASE_IDENTIFIER_VALID); + await page.locator(submitSelector).click(); + await screenshot( + "webform-handler-add-archive-on-case-filled-valid-case-identifier", + ".messages__wrapper", + ); + + await goto("admin/structure/webform/manage/os2forms_f2_ex_000/handlers"); + await screenshot("webform-handler-added", ".page-wrapper"); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_000/handlers/f2_arkiver_paa_sag/edit", + ); + await screenshot( + "webform-handler-archive-on-case-case-details", + // "#edit-settings-os2forms-f2-f2-archive-archive-target-case", + ); + + // Archive on matter + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_000/handlers/add/os2forms_f2_f2", + ); + + await page.locator('[name="label"]').fill("F2 – arkivér på akt"); + // Unhide and fill machine name field. + await page.evaluate( + 'document.querySelector(".form-type-machine-name").classList.remove("hidden")', + ); + await page.locator('[name="handler_id"]').fill("f2_arkiver_paa_akt"); + await page + .locator('[name="settings[os2forms_f2_f2][archive][attachment_element]"]') + .selectOption("attachment"); + await page + .locator('[name="settings[os2forms_f2_f2][archive][document_title]"]') + .fill("[webform_submission:label] ([site:url])"); + await page + .locator('[name="settings[os2forms_f2_f2][archive][archive_target]"]') + .focus(); + + // await screenshot('webform-handler-add-general-archive-settings-filled.png', '#edit-settings-os2forms-f2-f2'); + + await page + .locator('[name="settings[os2forms_f2_f2][archive][archive_target]"]') + .selectOption("matter"); + await screenshot( + "webform-handler-add-archive-on-matter", + "#edit-settings-os2forms-f2-f2-archive-archive-target-matter", + ); + + await page + .locator( + '[name="settings[os2forms_f2_f2][archive][archive_target_matter][matter_identifier]"]', + ) + .fill(process.env.F2_MATTER_IDENTIFIER_INVALID); + await screenshot( + "webform-handler-add-archive-on-matter-filled", + "#edit-settings-os2forms-f2-f2-archive-archive-target-matter", + ); + + await page.locator(submitSelector).click(); + await screenshot( + "webform-handler-add-archive-on-matter-filled-invalid-matter-identifier", + ".messages__wrapper", + ); + + await page + .locator( + '[name="settings[os2forms_f2_f2][archive][archive_target_matter][matter_identifier]"]', + ) + .fill(process.env.F2_MATTER_IDENTIFIER_VALID); + await page.locator(submitSelector).click(); + await screenshot( + "webform-handler-add-archive-on-matter-filled-valid-matter-identifier", + ".messages__wrapper", + ); + + await goto("admin/structure/webform/manage/os2forms_f2_ex_000/handlers"); + await screenshot("webform-handler-added-2", ".page-wrapper"); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_000/handlers/f2_arkiver_paa_akt/edit", + ); + await screenshot( + "webform-handler-archive-on-matter-matter-details", + "#edit-settings-os2forms-f2-f2-archive-archive-target-matter", + ); + + // Existing handler + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_001/handlers/f2_archive_on_case/edit", + ); + await screenshot( + "webform-handler-archive-on-case", + "#edit-settings-os2forms-f2-f2", + ); + await screenshot( + "webform-handler-archive-on-case-case", + "#edit-settings-os2forms-f2-f2-archive-archive-target-case", + ); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_001/handlers/f2_archive_on_matter/edit", + ); + await screenshot( + "webform-handler-archive-on-matter", + "#edit-settings-os2forms-f2-f2", + ); + await screenshot( + "webform-handler-archive-on-matter-matter", + "#edit-settings-os2forms-f2-f2-archive-archive-target-matter", + ); + + await goto("admin/structure/webform/manage/os2forms_f2_ex_001/handlers"); + await screenshot("webform-handlers", "#webform-handlers"); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_001/handlers/f2_archive_on_case/edit", + ); + await page.getByRole("tab", { name: "Avanceret" }).click(); + await expect(page.getByText("Yderligere indstillinger")).toBeVisible(); + await page.getByText("Yderligere indstillinger").click(); + await page.getByText("Yderligere indstillinger").blur(); + await screenshot("webform-handler-add-advanced", "#webform-handler-form"); +}); diff --git a/tests/Playwright/tests/user-manual-submission.spec.ts b/tests/Playwright/tests/user-manual-submission.spec.ts new file mode 100644 index 0000000..a4e489c --- /dev/null +++ b/tests/Playwright/tests/user-manual-submission.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from "@playwright/test"; + +// We want run and complete the administrator stuff before running the form-builder stuff. +// https://playwright.dev/docs/test-parallel +test.describe.configure({ mode: "serial" }); + +import dotenv from "dotenv"; +import path from "path"; +import * as child_process from "node:child_process"; +dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); + +test("webform submission", async ({ page }) => { + const locale = "da"; + const context = "webform-submission"; + let screenshotCounter = 0; + + const screenshot = async ( + name: string, + selector: string = ".page-wrapper", + ) => { + const filePath = path.join( + `/docs/user-manual/${locale}/images/generated/`, + context, + String(screenshotCounter++).padStart(3, "0") + "-" + name + ".png", + ); + await page.locator(selector).screenshot({ path: filePath }); + }; + + const drush = async (args: string[]) => { + return page.request.post("/os2forms-test-playwright/exec/drush", { + data: args, + timeout: 4000, + }); + }; + + const goto = async (url: string) => { + url = `/${locale}/${url}`; + console.debug(`going to ${url} …`); + return page.goto(url); + }; + + await drush([ + "sql:query", + [ + "TRUNCATE advancedqueue", + "TRUNCATE webform_submission_log", + "TRUNCATE webform_submission_data", + "TRUNCATE webform_submission", + "ALTER TABLE webform_submission AUTO_INCREMENT = 1", + "UPDATE webform SET next_serial = 1", + ].join(";"), + ]); + + await goto("webform/os2forms_f2_ex_001"); + await screenshot("form-empty"); + + await page + .locator('input[data-drupal-selector="edit-name"]') + .fill("Anders And"); + await screenshot("form-filled"); + + await page.locator('input[data-drupal-selector="edit-submit"]').click(); + await screenshot("form-submitted"); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_001/submission/1/log", + ); + await screenshot("submission-log"); + + await drush(["advancedqueue:queue:process", "os2forms_f2"]); + + await goto( + "admin/structure/webform/manage/os2forms_f2_ex_001/submission/1/log", + ); + await screenshot("submission-log-after-queue-processed"); +}); diff --git a/translations/os2forms_f2.da.po b/translations/os2forms_f2.da.po index a02b4ce..ff9bd7a 100644 --- a/translations/os2forms_f2.da.po +++ b/translations/os2forms_f2.da.po @@ -3,8 +3,8 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" -"POT-Creation-Date: 2026-09-28 10:24+0200\n" -"PO-Revision-Date: 2026-09-28 10:24+0200\n" +"POT-Creation-Date: 2026-10-27 23:57+0100\n" +"PO-Revision-Date: 2026-10-27 23:57+0100\n" "Last-Translator: NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" @@ -13,131 +13,271 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Language: da\n" +msgctxt "os2forms_f2" +msgid "Edit to see details." +msgstr "Rediger for at se detaljer." + +msgctxt "os2forms_f2" +msgid "draft is created." +msgstr "kladde oprettes." + +msgctxt "os2forms_f2" +msgid "draft is updated." +msgstr "kladde opdateres." + +msgctxt "os2forms_f2" +msgid "submission is completed." +msgstr "besvarelse afsluttes." + +msgctxt "os2forms_f2" +msgid "submission is deleted." +msgstr "besvarelse slettes." + +msgctxt "os2forms_f2" +msgid "submission is locked." +msgstr "besvarelse låses." + +msgctxt "os2forms_f2" +msgid "submission is updated." +msgstr "besvarelse opdateres." + +msgctxt "os2forms_f2" +msgid "API log level" +msgstr "Api-logniveau" + +msgctxt "os2forms_f2" +msgid "Additional settings" +msgstr "Yderligere indstillinger" + +msgctxt "os2forms_f2" +msgid "Alert" +msgstr "Advarsel" + +msgctxt "os2forms_f2" +msgid "Archive on case \"@case_identifier\"." +msgstr "Arkivér på sag \"@case_identifier\"." + +msgctxt "os2forms_f2" +msgid "Archive on matter \"@matter_identifier\"." +msgstr "Arkivér på akt \"@matter_identifier\"." + +msgctxt "os2forms_f2" +msgid "Archive target" +msgstr "Arkivér på" + +msgctxt "os2forms_f2" +msgid "Attachment element" +msgstr "Vedhæftningselement" + +msgctxt "os2forms_f2" +msgid "Cannot get case by identifier \"@case_identifier\" (@message)." +msgstr "Sag med id \"@case_identifier\" ikke fundet (@message)." + +msgctxt "os2forms_f2" +msgid "Cannot get matter by identifier \"@matter_identifier\" (@message)." +msgstr "Akt med id \"@matter_identifier\" ikke fundet (@message)." + +msgctxt "os2forms_f2" +msgid "Case" +msgstr "Sag" + +msgctxt "os2forms_f2" +msgid "Case details" +msgstr "Sagsdetaljer" + +msgctxt "os2forms_f2" +msgid "Case identifier" +msgstr "Sags-id" + +msgctxt "os2forms_f2" +msgid "Case number or ID that uniquely identifies a single case." +msgstr "Sagsnummer eller -id der entydigt identificerer en enkelt sag." + +msgctxt "os2forms_f2" +msgid "Configure the OS2Forms F2 module" +msgstr "Konfigurer F2-modulet" + +msgctxt "os2forms_f2" +msgid "Critical" +msgstr "Kritisk" + +msgctxt "os2forms_f2" +msgid "Debug" +msgstr "Fejlsøgning" + +msgctxt "os2forms_f2" +msgid "Document title" +msgstr "Dokumenttitel" + +msgctxt "os2forms_f2" +msgid "Emergency" +msgstr "Nødsituation" + +msgctxt "os2forms_f2" +msgid "Error" +msgstr "Fejl" + +msgctxt "os2forms_f2" +msgid "Error: @error" +msgstr "Fejl: @error" + +msgctxt "os2forms_f2" +msgid "F2" +msgstr "F2" + msgctxt "os2forms_f2" msgid "F2 API" msgstr "F2-api" +msgctxt "os2forms_f2" +msgid "F2 archive settings" +msgstr "F2-arkiveringsindstillinger" + +msgctxt "os2forms_f2" +msgid "F2 settings" +msgstr "F2-indstillinger" + +msgctxt "os2forms_f2" +msgid "F2 username" +msgstr "F2-brugernavn" + msgctxt "os2forms_f2" msgid "General" msgstr "Generelt" msgctxt "os2forms_f2" -msgid "Ping API" -msgstr "Tjek forbindelse til api" +msgid "Identifier \"@case_identifier\" does not uniquely identify a case." +msgstr "Id'et \"@case_identifier\" identificerer ikke entydigt en sag." msgctxt "os2forms_f2" -msgid "Note: Pinging the API will use saved config." -msgstr "Bemærk: Forbindelsen bliver tjekket med de gemte indstillinger." +msgid "Inbound" +msgstr "Indgående" msgctxt "os2forms_f2" -msgid "URI" -msgstr "Url" +msgid "Info" +msgstr "Info" msgctxt "os2forms_f2" -msgid "The F2 API base URI" -msgstr "F2-api'ets base-url" +msgid "Internal" +msgstr "Intern" msgctxt "os2forms_f2" -msgid "Username" -msgstr "Bruger" +msgid "Invalid archive target: @target" +msgstr "Ugyldig arkivering: @target" msgctxt "os2forms_f2" -msgid "The F2 API username" -msgstr "F2-api-brugernavnet" +msgid "Matter" +msgstr "Akt" msgctxt "os2forms_f2" -msgid "Secret" -msgstr "Hemmelighed (secret)" +msgid "Matter details" +msgstr "Aktdetaljer" msgctxt "os2forms_f2" -msgid "The F2 API secret" -msgstr "F2-api-hemmeligheden" +msgid "Matter identifier" +msgstr "Akt-id" msgctxt "os2forms_f2" -msgid "F2 username" -msgstr "F2-brugernavn" +msgid "Matter number or ID that uniquely identifies a single matter." +msgstr "Aktnummer eller -id der entydigt identificerer en enkelt akt." msgctxt "os2forms_f2" -msgid "The F2 username to act on behalf of" -msgstr "F2-brugernavnet som der skal ageres på vegne af" +msgid "Matter title" +msgstr "Akttitel" msgctxt "os2forms_f2" -msgid "Queue for F2 jobs." -msgstr "Kø til F2-jobs." +msgid "Matter type" +msgstr "Akttype" msgctxt "os2forms_f2" -msgid "" -"Queue for F2 jobs. The queue must be run via " -"Drupal's cron or via drush advancedqueue:queue:process " -"@queue (in a cron job)." -msgstr "" -"Kø til F2-jobs. Køen skal behandles via " -"Drupals cron eller med drush advancedqueue:queue:process " -"@queue (i et cron-job)." +msgid "Missing case identifier." +msgstr "Manglende sags-id." msgctxt "os2forms_f2" -msgid "Queue" -msgstr "Kø" +msgid "Missing matter identifier." +msgstr "Manglende akt-id." msgctxt "os2forms_f2" msgid "No queue" msgstr "Ingen kø" +msgctxt "os2forms_f2" +msgid "Note: Pinging the API will use saved config." +msgstr "Bemærk: Forbindelsen bliver tjekket med de gemte indstillinger." + +msgctxt "os2forms_f2" +msgid "Notice" +msgstr "Bemærkning" + +msgctxt "os2forms_f2" +msgid "OS2Forms F2" +msgstr "F2" + +msgctxt "os2forms_f2" +msgid "Outbound" +msgstr "Udgående" + +msgctxt "os2forms_f2" +msgid "Ping API" +msgstr "Tjek forbindelse til api" + msgctxt "os2forms_f2" msgid "Pinged API successfully." msgstr "Forbindelse opnået." msgctxt "os2forms_f2" msgid "Pinging API failed: @message" -msgstr "Fejl under forbindelse til api: @message" +msgstr "Kan ikke oprette forbindelse til api: @message" msgctxt "os2forms_f2" -msgid "Additional settings" -msgstr "Yderligere indstillinger" +msgid "Pinging API failed: @message (@previous_message)" +msgstr "Kan ikke oprette forbindelse til api: @message (@previous_message)" msgctxt "os2forms_f2" -msgid "Run handler when …" -msgstr "Kør behandler (handler) når …" - -msgctxt "os2forms_f2" -msgid "draft is created." -msgstr "kladde oprettes." +msgid "Queue" +msgstr "Kø" msgctxt "os2forms_f2" -msgid "draft is updated." -msgstr "kladde opdateres." +msgid "Queue for F2 jobs." +msgstr "Kø til F2-jobs." msgctxt "os2forms_f2" -msgid "anonymous submission is converted to authenticated." -msgstr "anonym besvarelse ændres til ikke-anonym." +msgid "" +"Queue for F2 jobs. The queue must be run via " +"Drupal's cron or via drush advancedqueue:queue:process " +"@queue (in a cron job)." +msgstr "" +"Kø til F2-jobs. Køen skal behandles via " +"Drupals cron eller med drush advancedqueue:queue:process " +"@queue (i et cron-job)." msgctxt "os2forms_f2" -msgid "submission is completed." -msgstr "besvarelse afsluttes." +msgid "Run handler when …" +msgstr "Kør behandler (handler) når …" msgctxt "os2forms_f2" -msgid "submission is updated." -msgstr "besvarelse opdateres." +msgid "Secret" +msgstr "Hemmelighed (secret)" msgctxt "os2forms_f2" -msgid "submission is deleted." -msgstr "besvarelse slettes." +msgid "Sends webform submission to F2." +msgstr "Sender formularbesvarelser til F2." msgctxt "os2forms_f2" -msgid "submission is locked." -msgstr "besvarelse låses." +msgid "The F2 API base URI" +msgstr "F2-api'ets base-url" msgctxt "os2forms_f2" -msgid "F2 archive settings" -msgstr "F2-arkiveringsindstilinger" +msgid "The F2 API secret" +msgstr "F2-api-hemmeligheden" msgctxt "os2forms_f2" -msgid "Attachment element" -msgstr "Vedhæftningselement" +msgid "The F2 API username" +msgstr "F2-api-brugernavnet" msgctxt "os2forms_f2" -msgid "Document title" -msgstr "Dokumenttitel" +msgid "The F2 username to act on behalf of" +msgstr "F2-brugernavnet som der skal ageres på vegne af" msgctxt "os2forms_f2" msgid "" @@ -148,42 +288,35 @@ msgstr "" "[webform_submission:label]." msgctxt "os2forms_f2" -msgid "Archive target" -msgstr "Arkivér på" - -msgctxt "os2forms_f2" -msgid "Matter ID" -msgstr "Akt-id" - -msgctxt "os2forms_f2" -msgid "Matter" -msgstr "Akt" +msgid "" +"The title of the matter that will be created for the document. Tokens " +"can be used in the title, e.g. " +"[webform_submission:label]." +msgstr "" +"Titlen på akten der bliver oprettet til dokumentet. Understøtter " +"\"tokens\", fx [webform_submission:label]." msgctxt "os2forms_f2" -msgid "Missing or invalid matter ID." -msgstr "Manglende eller ugyldigt akt-id." +msgid "The type of the matter that will be created for the document." +msgstr "Typen på akten der bliver oprettet for dokumentet." msgctxt "os2forms_f2" -msgid "Cannot get matter by ID @matter_id (@message)." -msgstr "Akt med id @matter_id findes ikke (@message)." +msgid "URI" +msgstr "Url" msgctxt "os2forms_f2" -msgid "Archive on matter @matter_id" -msgstr "Arkivér på akt @matter_id" +msgid "Username" +msgstr "Bruger" msgctxt "os2forms_f2" -msgid "F2" -msgstr "F2" +msgid "Warning" +msgstr "Advarsel" msgctxt "os2forms_f2" msgid "Web services" msgstr "Webservices" msgctxt "os2forms_f2" -msgid "Sends webform submission to F2." -msgstr "Sender formularbesvarelser til F2." - -msgctxt "os2forms_f2" -msgid "OS2Forms: F2 integration" -msgstr "OS2Forms: F2-integration" +msgid "anonymous submission is converted to authenticated." +msgstr "anonym besvarelse ændres til ikke-anonym."