From dc3e6eeeb9d534806987d964115069bab2c70949 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Mon, 18 May 2026 12:07:27 +0200 Subject: [PATCH 1/9] os2forms_f2 --- .github/workflows/changelog.yaml | 29 +++++++ .github/workflows/composer.yaml | 85 +++++++++++++++++++ .github/workflows/markdown.yaml | 44 ++++++++++ .github/workflows/php.yaml | 64 ++++++++++++++ .github/workflows/project.yaml | 30 +++++++ .github/workflows/yaml.yaml | 41 +++++++++ .gitignore | 2 + .markdownlint.jsonc | 22 +++++ .markdownlintignore | 12 +++ .phpcs.xml.dist | 33 ++++++++ CHANGELOG.md | 10 +++ Taskfile.yml | 118 ++++++++++++++++++++++++++ compose.yaml | 32 +++++++ composer.json | 31 +++++++ os2forms_f2.info.yml | 7 ++ phpstan.neon | 14 ++++ rector.php | 21 +++++ scripts/.env | 2 + scripts/base | 138 +++++++++++++++++++++++++++++++ scripts/code-analysis | 13 +++ scripts/compose.yaml | 22 +++++ scripts/rector | 13 +++ 22 files changed, 783 insertions(+) create mode 100644 .github/workflows/changelog.yaml create mode 100644 .github/workflows/composer.yaml create mode 100644 .github/workflows/markdown.yaml create mode 100644 .github/workflows/php.yaml create mode 100644 .github/workflows/project.yaml create mode 100644 .github/workflows/yaml.yaml create mode 100644 .gitignore create mode 100644 .markdownlint.jsonc create mode 100644 .markdownlintignore create mode 100644 .phpcs.xml.dist create mode 100644 CHANGELOG.md create mode 100644 Taskfile.yml create mode 100644 compose.yaml create mode 100644 composer.json create mode 100644 os2forms_f2.info.yml create mode 100644 phpstan.neon create mode 100644 rector.php create mode 100644 scripts/.env create mode 100644 scripts/base create mode 100755 scripts/code-analysis create mode 100644 scripts/compose.yaml create mode 100755 scripts/rector diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 0000000..fead572 --- /dev/null +++ b/.github/workflows/changelog.yaml @@ -0,0 +1,29 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/changelog.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Changelog +### +### Checks that changelog has been updated + +name: Changelog + +on: + pull_request: + +jobs: + changelog: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml new file mode 100644 index 0000000..5fb0ef0 --- /dev/null +++ b/.github/workflows/composer.yaml @@ -0,0 +1,85 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/composer.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Composer +### +### Validates composer.json and checks that it's normalized. +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize +### ``` +### +### Normalize `composer.json` by running +### +### ``` shell +### docker compose run --rm phpfpm composer normalize +### ``` + +name: Composer + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + composer-validate: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v5 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer validate --strict + + composer-normalized: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v5 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer init --no-interaction + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer require mglaman/composer-drupal-lenient + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm rm composer.lenient.* + + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm composer normalize --dry-run + + composer-audit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v5 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer audit diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml new file mode 100644 index 0000000..ae83163 --- /dev/null +++ b/.github/workflows/markdown.yaml @@ -0,0 +1,44 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/markdown.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Markdown +### +### Lints Markdown files (`**/*.md`) in the project. +### +### [markdownlint-cli configuration +### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration), +### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually +### linted and how. +### +### #### Assumptions +### +### 1. A docker compose service named `markdownlint` for running `markdownlint` +### (from +### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli)) +### exists. + +name: Markdown + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + markdown-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm markdownlint markdownlint '**/*.md' diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml new file mode 100644 index 0000000..3b6415d --- /dev/null +++ b/.github/workflows/php.yaml @@ -0,0 +1,64 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/drupal-module/php.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Drupal module PHP +### +### Checks that PHP code adheres to the [Drupal coding +### standards](https://www.drupal.org/docs/develop/standards). +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [drupal/coder](https://www.drupal.org/project/coder) is a dev requirement +### in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev drupal/coder +### ``` +### +### Clean up and check code by running +### +### ``` shell +### docker compose run --rm phpfpm vendor/bin/phpcbf +### docker compose run --rm phpfpm vendor/bin/phpcs +### ``` +### +### > [!NOTE] +### > The template adds `.phpcs.xml.dist` as [a configuration file for +### > PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage#using-a-default-configuration-file) +### > and this makes it possible to override the actual configuration used in a +### > project by adding a more important configuration file, e.g. `.phpcs.xml`. + +name: PHP + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + coding-standards: + name: PHP - Check Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer init --no-interaction + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer require mglaman/composer-drupal-lenient + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm rm composer.lenient.* + + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm vendor/bin/phpcs diff --git a/.github/workflows/project.yaml b/.github/workflows/project.yaml new file mode 100644 index 0000000..da5ae3d --- /dev/null +++ b/.github/workflows/project.yaml @@ -0,0 +1,30 @@ +name: Project + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + code-analysis: + name: PHP - Code analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - run: | + ./scripts/code-analysis + + rector: + name: PHP - Rector + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - run: | + ./scripts/rector diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml new file mode 100644 index 0000000..631e525 --- /dev/null +++ b/.github/workflows/yaml.yaml @@ -0,0 +1,41 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/yaml.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### YAML +### +### Validates YAML files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. +### +### #### Symfony YAML +### +### Symfony's YAML config files use 4 spaces for indentation and single quotes. +### Therefore we use a [Prettier configuration +### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make +### Prettier format YAML files in the `config/` folder like Symfony expects. + +name: YAML + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + yaml-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier '**/*.{yml,yaml}' --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8a7996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..0253096 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,22 @@ +// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker. +// Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration) +{ + "default": true, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md + "line-length": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "no-duplicate-heading": { + "siblings_only": true + }, + // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md + "no-inline-html": { + "allowed_elements": ["details", "summary"] + } +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..d143ace --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,12 @@ +# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files +vendor/ +node_modules/ +LICENSE.md +# Drupal +web/*.md +web/core/ +web/libraries/ +web/*/contrib/ diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist new file mode 100644 index 0000000..083aa41 --- /dev/null +++ b/.phpcs.xml.dist @@ -0,0 +1,33 @@ + + + + + + The coding standard. + + . + + + vendor + rector.php + + + + + + + + + + + + + + + + + + + src/Settings/ + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b337319 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/itk-dev/os2forms_f2 diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..b4d390e --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,118 @@ +# https://taskfile.dev + +version: "3" + +tasks: + compose: + cmds: + - docker compose {{.TASK_ARGS}} {{.CLI_ARGS}} + internal: true + + composer: + desc: Run composer inside docker compose setup, e.g. `task {{.TASK}} -- install` + cmds: + - task: compose + vars: + TASK_ARGS: run --rm phpfpm composer {{.TASK_ARGS}} + + composer:install: + desc: Run composer inside docker compose setup, e.g. `task {{.TASK}} -- install` + cmds: + - rm -fr composer.lock vendor + - | + # Create a temporary composer file to install https://github.com/mglaman/composer-drupal-lenient before the real install needs it. + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer init --no-interaction + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer config --no-plugins allow-plugins.mglaman/composer-drupal-lenient true + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm composer require mglaman/composer-drupal-lenient + docker compose run --rm --env COMPOSER=composer.lenient.json phpfpm rm composer.lenient.* + + - task: composer + vars: + TASK_ARGS: install + + coding-standards:apply: + desc: "Apply coding standards" + cmds: + - task: coding-standards:composer:apply + - task: coding-standards:markdown:apply + - task: coding-standards:php:apply + - task: coding-standards:yaml:apply + silent: true + + coding-standards:check: + desc: "Check coding standards" + cmds: + - task: coding-standards:composer:check + - task: coding-standards:markdown:check + - task: coding-standards:php:check + - task: coding-standards:yaml:check + silent: true + + coding-standards:composer:apply: + desc: "Apply coding standards for composer" + cmds: + - task: compose + vars: + TASK_ARGS: run --rm phpfpm composer normalize + + coding-standards:composer:check: + - task: coding-standards:composer:apply + - task: compose + vars: + TASK_ARGS: run --rm phpfpm composer normalize --dry-run + - task: compose + vars: + TASK_ARGS: run --rm phpfpm composer validate + + coding-standards:markdown:apply: + desc: "Apply coding standards for Markdown" + cmds: + # Cf. .github/workflows/markdown.yaml + - docker compose run --rm markdownlint markdownlint '**/*.md' --fix + + coding-standards:markdown:check: + desc: "Apply and check coding standards for Markdown" + cmds: + - task: coding-standards:markdown:apply + # Cf. .github/workflows/markdown.yaml + - docker compose run --rm markdownlint markdownlint '**/*.md' + + coding-standards:php:apply: + desc: "Apply coding standards for PHP" + cmds: + # Cf. .github/workflows/php.yaml + - docker compose run --rm phpfpm vendor/bin/phpcbf + silent: true + + coding-standards:php:check: + desc: "Apply and check coding standards for PHP" + cmds: + - task: coding-standards:php:apply + # Cf. .github/workflows/php.yaml + - docker compose run --rm phpfpm vendor/bin/phpcs + silent: true + + coding-standards:yaml:apply: + desc: "Apply coding standards for YAML" + cmds: + # Cf. .github/workflows/yaml.yaml + - docker compose run --rm prettier '**/*.{yml,yaml}' --write + + coding-standards:yaml:check: + desc: "Apply coding standards for YAML" + cmds: + - task: coding-standards:yaml:apply + # Cf. .github/workflows/yaml.yaml + - docker compose run --rm prettier '**/*.{yml,yaml}' --check + + test: + cmds: + - docker compose run --env PHP_XDEBUG_MODE --env PHP_XDEBUG_WITH_REQUEST + --env PHP_IDE_CONFIG --rm phpfpm vendor/bin/phpunit {{.CLI_ARGS}} + + xdebug:test: + cmds: + - PHP_XDEBUG_MODE=debug PHP_XDEBUG_WITH_REQUEST=yes + PHP_IDE_CONFIG=serverName=localhost docker compose run --env + PHP_XDEBUG_MODE --env PHP_XDEBUG_WITH_REQUEST --env PHP_IDE_CONFIG --rm + phpfpm vendor/bin/phpunit {{.CLI_ARGS}} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c4f59d0 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,32 @@ +services: + phpfpm: + image: itkdev/php8.3-fpm:latest + user: ${COMPOSE_USER:-deploy} + profiles: + - dev + volumes: + - .:/app-os2forms_f2 + working_dir: /app-os2forms_f2 + environment: + # https://getcomposer.org/doc/03-cli.md#composer-no-security-blocking + # @see https://github.com/OS2Forms/os2forms/issues/245 + COMPOSER_NO_SECURITY_BLOCKING: 1 + + # Code checks tools + markdownlint: + image: itkdev/markdownlint + profiles: + - dev + volumes: + - ./:/md + + prettier: + # Prettier does not (yet, fcf. + # 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 + volumes: + - ./:/work diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8c574c2 --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "os2forms/os2forms_f2", + "description": "OS2Forms: F2 integration", + "license": "MIT", + "type": "drupal-module", + "require": { + "itk-dev/f2-api-client": "dev-f2-api-client as 1.0.0", + "os2forms/os2forms": "^5.0" + }, + "require-dev": { + "drupal/coder": "^9.0", + "ergebnis/composer-normalize": "^2.52" + }, + "repositories": [ + { + "type": "composer", + "url": "https://packages.drupal.org/8" + } + ], + "minimum-stability": "dev", + "config": { + "allow-plugins": { + "cweagans/composer-patches": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "ergebnis/composer-normalize": true, + "mglaman/composer-drupal-lenient": true, + "simplesamlphp/composer-module-installer": true, + "zaporylie/composer-drupal-optimizations": true + } + } +} diff --git a/os2forms_f2.info.yml b/os2forms_f2.info.yml new file mode 100644 index 0000000..3e5f5ee --- /dev/null +++ b/os2forms_f2.info.yml @@ -0,0 +1,7 @@ +name: "os2forms_f2" +type: module +description: "OS2Forms: F2 integration" +package: OS2Forms +core_version_requirement: ^10 || ^11 +dependencies: + - os2forms:os2forms diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..716b6c6 --- /dev/null +++ b/phpstan.neon @@ -0,0 +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 (?) + + 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.#' diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..44fe530 --- /dev/null +++ b/rector.php @@ -0,0 +1,21 @@ +withPaths([ + __DIR__ . '/src', + // __DIR__ . '/tests', + ]) + ->withSets([ + Drupal10SetList::DRUPAL_10, + ]) + ->withPhpSets(php83: TRUE) + ->withTypeCoverageLevel(0); diff --git a/scripts/.env b/scripts/.env new file mode 100644 index 0000000..da1e572 --- /dev/null +++ b/scripts/.env @@ -0,0 +1,2 @@ +COMPOSE_PROJECT_NAME=drupal-module +MODULE_NAME=os2forms_f2 diff --git a/scripts/base b/scripts/base new file mode 100644 index 0000000..def2c9d --- /dev/null +++ b/scripts/base @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -o errexit -o errtrace -o noclobber -o nounset -o pipefail +IFS=$'\n\t' + +execute_name=execute + +usage() { + (cat >&2 </dev/null); then + (cat >&2 <&2 < Date: Thu, 28 May 2026 11:00:23 +0200 Subject: [PATCH 2/9] os2forms_f2 --- ...dqueue.advancedqueue_queue.os2forms_f2.yml | 16 ++ os2forms_f2.info.yml | 8 + os2forms_f2.routing.yml | 7 + os2forms_f2.services.yml | 16 ++ src/Exception/Exception.php | 10 + .../InvalidAttachmentElementException.php | 10 + src/Exception/RuntimeException.php | 10 + src/Exception/SubmissionNotFoundException.php | 10 + src/Form/SettingsForm.php | 186 ++++++++++++++ src/Helper/WebformHelperF2.php | 237 ++++++++++++++++++ src/Plugin/AdvancedQueue/JobType/F2.php | 62 +++++ .../WebformHandler/WebformHandlerF2.php | 181 +++++++++++++ src/Settings.php | 78 ++++++ src/Settings/AbstractSettings.php | 173 +++++++++++++ src/Settings/ArchiveSettings.php | 34 +++ .../ArchiveSettings/ArchiveTarget.php | 10 + .../ArchiveSettings/ArchiveTargetCase.php | 15 ++ src/Settings/F2ApiSettings.php | 23 ++ src/Settings/GeneralSettings.php | 17 ++ src/Settings/HandlerSettings.php | 19 ++ 20 files changed, 1122 insertions(+) create mode 100644 config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml create mode 100644 os2forms_f2.routing.yml create mode 100644 os2forms_f2.services.yml create mode 100644 src/Exception/Exception.php create mode 100644 src/Exception/InvalidAttachmentElementException.php create mode 100644 src/Exception/RuntimeException.php create mode 100644 src/Exception/SubmissionNotFoundException.php create mode 100644 src/Form/SettingsForm.php create mode 100644 src/Helper/WebformHelperF2.php create mode 100644 src/Plugin/AdvancedQueue/JobType/F2.php create mode 100644 src/Plugin/WebformHandler/WebformHandlerF2.php create mode 100644 src/Settings.php create mode 100644 src/Settings/AbstractSettings.php create mode 100644 src/Settings/ArchiveSettings.php create mode 100644 src/Settings/ArchiveSettings/ArchiveTarget.php create mode 100644 src/Settings/ArchiveSettings/ArchiveTargetCase.php create mode 100644 src/Settings/F2ApiSettings.php create mode 100644 src/Settings/GeneralSettings.php create mode 100644 src/Settings/HandlerSettings.php diff --git a/config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml b/config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml new file mode 100644 index 0000000..a0c1842 --- /dev/null +++ b/config/install/advancedqueue.advancedqueue_queue.os2forms_f2.yml @@ -0,0 +1,16 @@ +langcode: en +status: true +dependencies: + module: + - os2forms_f2 + enforced: + module: + - os2forms_f2 +id: os2forms_f2 +label: "OSForms F2" +backend: database +backend_configuration: + lease_time: 300 +processor: cron +processing_time: 90 +locked: false diff --git a/os2forms_f2.info.yml b/os2forms_f2.info.yml index 3e5f5ee..a1efc23 100644 --- a/os2forms_f2.info.yml +++ b/os2forms_f2.info.yml @@ -5,3 +5,11 @@ package: OS2Forms core_version_requirement: ^10 || ^11 dependencies: - os2forms:os2forms + +configure: os2forms_f2.admin.settings + +"interface translation project": os2forms_f2 +# @todo Using module://%project/ here does not work … +# @todo Set a proper URL when the module has been released for real +# "interface translation server pattern": module://%project/translations/%project.%language.po +"interface translation server pattern": https://github.com/itk-dev/os2forms_f2/blob/os2forms_f2/translations/os2forms_f2.da.po diff --git a/os2forms_f2.routing.yml b/os2forms_f2.routing.yml new file mode 100644 index 0000000..9102eed --- /dev/null +++ b/os2forms_f2.routing.yml @@ -0,0 +1,7 @@ +os2forms_f2.admin.settings: + path: "/admin/os2forms_f2/settings" + defaults: + _title: "F2 settings" + _form: 'Drupal\os2forms_f2\Form\SettingsForm' + requirements: + _permission: "administer site configuration" diff --git a/os2forms_f2.services.yml b/os2forms_f2.services.yml new file mode 100644 index 0000000..fc1aa0c --- /dev/null +++ b/os2forms_f2.services.yml @@ -0,0 +1,16 @@ +services: + _defaults: + autowire: true + + logger.channel.os2forms_f2: + parent: logger.channel_base + arguments: ["os2forms_f2"] + + logger.channel.os2forms_f2_submission: + parent: logger.channel_base + arguments: ["webform_submission"] + + Drupal\os2forms_f2\Helper\WebformHelperF2: + + + Drupal\os2forms_f2\Settings: diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php new file mode 100644 index 0000000..f9e037b --- /dev/null +++ b/src/Exception/Exception.php @@ -0,0 +1,10 @@ +queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'os2forms_f2_settings'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames(): array { + return [Settings::CONFIG_NAME]; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function buildForm(array $form, FormStateInterface $form_state): array { + $form[F2ApiSettings::NAME] = [ + '#type' => 'fieldset', + '#title' => $this->t('F2 API'), + '#tree' => TRUE, + ] + $this->buildFormF2Api(); + + $form[GeneralSettings::NAME] = [ + '#type' => 'fieldset', + '#title' => $this->t('General'), + '#tree' => TRUE, + ] + $this->buildFormGeneral(); + + return parent::buildForm($form, $form_state); + } + + /** + * Build form section "F2 API". + */ + private function buildFormF2Api(): array { + $settings = $this->settings->getF2ApiSettings(); + + $section[F2ApiSettings::URI] = [ + '#type' => 'url', + '#required' => TRUE, + '#title' => $this->t('URI'), + '#default_value' => $settings->uri, + '#description' => $this->t('The F2 API base URI'), + ]; + + $section[F2ApiSettings::USERNAME] = [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Username'), + '#default_value' => $settings->username, + '#description' => $this->t('The F2 API username'), + ]; + + $section[F2ApiSettings::SECRET] = [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Secret'), + '#default_value' => $settings->secret, + '#description' => $this->t('The F2 API secret'), + ]; + + $section[F2ApiSettings::F2_USERNAME] = [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('F2 username'), + '#default_value' => $settings->f2Username, + '#description' => $this->t('The F2 username to act on behalf of'), + ]; + + return $section; + } + + /** + * Build form section "General". + */ + private function buildFormGeneral(): array { + $settings = $this->settings->getGeneralSettings(); + + $description = empty($settings->queue) + ? $this->t('Queue for F2 jobs.') + : $this->t("Queue for F2 jobs. The queue must be run via Drupal's cron or via drush advancedqueue:queue:process @queue (in a cron job).", + [ + '@queue' => $settings->queue, + ':queue_url' => '/admin/config/system/queues/jobs/' . urlencode((string) $settings->queue), + ]); + $section[GeneralSettings::QUEUE] = [ + '#type' => 'select', + '#required' => TRUE, + '#title' => $this->t('Queue'), + '#options' => array_map( + static fn(EntityInterface $queue) => $queue->label(), + $this->queueStorage->loadMultiple() + ), + '#empty_option' => $this->t('No queue'), + '#default_value' => $settings->queue, + '#description' => $description, + ]; + + $section[GeneralSettings::TEST_MODE] = [ + '#type' => 'checkbox', + '#title' => $this->t('Test mode'), + '#default_value' => $settings->testMode, + ]; + + return $section; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function validateForm(array &$form, FormStateInterface $form_state): void { + $setError = static fn(string|array $path, TranslatableMarkup $message) => $form_state->setErrorByName(implode('][', (array) $path), $message); + + // @todo Validate something? + parent::validateForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function submitForm(array &$form, FormStateInterface $form_state): void { + $config = $this->config(Settings::CONFIG_NAME); + foreach ([ + F2ApiSettings::NAME, + GeneralSettings::NAME, + ] as $name) { + $config->set($name, $form_state->getValue($name)); + } + $config->save(); + + parent::submitForm($form, $form_state); + } + +} diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php new file mode 100644 index 0000000..0c39502 --- /dev/null +++ b/src/Helper/WebformHelperF2.php @@ -0,0 +1,237 @@ +getStorage('webform_submission'); + $this->webformSubmissionStorage = $storage; + $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); + } + + /** + * Load webform submission by id. + */ + public function loadSubmission(int $id): ?WebformSubmissionInterface { + /** @var ?WebformSubmissionInterface $submission */ + $submission = $this->webformSubmissionStorage->load($id); + + return $submission; + } + + /** + * Load submission IDs for a webform. + */ + public function loadSubmissionIds(WebformInterface $webform): array { + return $this->webformSubmissionStorage->getQuery() + ->accessCheck() + ->condition('webform_id', $webform->id()) + ->sort('created', 'DESC') + ->sort('sid', 'DESC') + ->execute(); + } + + /** + * Load latest submission on a webform. + */ + public function loadLatestSubmission(WebformInterface $webform): ?WebformSubmissionInterface { + $submissionIds = $this->loadSubmissionIds($webform); + + $id = reset($submissionIds); + + return $id ? $this->loadSubmission($id) : NULL; + } + + /** + * Load queue. + */ + private function loadQueue(): QueueInterface { + $id = $this->settings->getGeneralSettings()->queue ?? NULL; + + /** @var ?\Drupal\advancedqueue\Entity\QueueInterface $queue */ + $queue = $this->queueStorage->load($id); + + if (NULL === $queue) { + throw new RuntimeException(sprintf('Cannot load queue %s', $id)); + } + + return $queue; + } + + /** + * {@inheritdoc} + * + * @param mixed $level + * The level. + * @param string $message + * The message. + * @param array $context + * The context. + * + * @phpstan-param array $context + */ + public function log($level, $message, array $context = []): void { + $this->logger->log($level, $message, $context); + // @see https://www.drupal.org/node/3020595 + if (isset($context['webform_submission']) && $context['webform_submission'] instanceof WebformSubmissionInterface) { + $this->submissionLogger->log($level, $message, $context); + } + } + + /** + * Create a job. + * + * @see self::processJob() + */ + public function createJob(WebformSubmissionInterface $webformSubmission, WebformHandlerF2|ArchiveSettings $handlerSettings, ?string $state = NULL, ?array $payload = []): ?Job { + $context = [ + 'handler_id' => WebformHandlerF2::ID, + 'webform_submission' => $webformSubmission, + ]; + + try { + if ($handlerSettings instanceof WebformHandlerF2) { + $handlerSettings = $this->settings->getHandlerSettings($handlerSettings); + } + + $job = Job::create(F2::class, [ + 'formId' => $webformSubmission->getWebform()->id(), + 'submissionId' => $webformSubmission->id(), + 'handlerSettings' => $handlerSettings->toArray(), + ]); + $queue = $this->loadQueue(); + $queue->enqueueJob($job); + $context['@queue'] = $queue->id(); + $this->notice('F2 job added to the queue @queue.', $context); + + return $job; + } + catch (\Exception $exception) { + $this->error('Error creating job for F2 archive: %message', $context + [ + '%message' => $exception->getMessage(), + 'operation' => 'F2 failed', + 'exception' => $exception, + ]); + return NULL; + } + } + + /** + * Process a job. + * + * @see self::createJob() + */ + public function processJob(Job $job): JobResult { + $payload = $job->getPayload(); + $context = [ + 'handler_id' => WebformHandlerF2::ID, + 'operation' => 'F2 archive', + ]; + try { + $submissionId = $payload['submissionId']; + $submission = $this->loadSubmission($submissionId); + if (NULL === $submission) { + $message = 'Cannot load submission @submissionId'; + $context = [ + '@submissionId' => $submissionId, + ]; + $this->error($message, $context); + + throw new SubmissionNotFoundException(str_replace(array_keys($context), array_values($context), + $message)); + } + + $context['webform_submission'] = $submission; + $handlerSettings = new HandlerSettings($payload['handlerSettings']); + + throw new \RuntimeException(__METHOD__ . 'not implemented'); + + return JobResult::success(); + } + catch (\Exception $e) { + $this->error('Error: @message', $context + [ + '@message' => $e->getMessage(), + 'exception' => $e, + ]); + + return JobResult::failure($e->getMessage()); + } + } + + /** + * Replace tokens in handler settings supporting tokens. + */ + private function replaceTokens(HandlerSettings $handlerSettings, WebformSubmissionInterface $submission): ArchiveSettings { + // @todo Should we clone the settings before making changes? + return $handlerSettings; + } + +} diff --git a/src/Plugin/AdvancedQueue/JobType/F2.php b/src/Plugin/AdvancedQueue/JobType/F2.php new file mode 100644 index 0000000..e0705ab --- /dev/null +++ b/src/Plugin/AdvancedQueue/JobType/F2.php @@ -0,0 +1,62 @@ + $configuration + */ + 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} + * + * @phpstan-param array $configuration + */ + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + /** + * The webform helper. + */ + private readonly WebformHelperF2 $helper, + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + /** + * {@inheritdoc} + */ + public function process(Job $job): JobResult { + return $this->helper->processJob($job); + } + +} diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php new file mode 100644 index 0000000..ebbb38d --- /dev/null +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -0,0 +1,181 @@ +settingsService = $container->get(Settings::class); + $instance->helper = $container->get(WebformHelperF2::class); + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getOffCanvasWidth(): string { + return WebformDialogHelper::DIALOG_NONE; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $settings = $this->settingsService->getArchiveSettings((array) ($this->getSettings()[ArchiveSettings::NAME] ?? NULL)); + + $form[ArchiveSettings::NAME] = [ + ArchiveSettings::ATTACHMENT_ELEMENT => [ + '#type' => 'select', + '#required' => TRUE, + '#title' => $this->t('Attachment element'), + '#default_value' => $settings->attachmentElement, + '#options' => $this->getAttachmentElements(), + ], + + ArchiveSettings::ARCHIVE_TARGET => [ + '#type' => 'select', + '#required' => TRUE, + '#title' => $this->t('Archive target'), + '#default_value' => $settings->archiveTarget?->value, + '#options' => [ + ArchiveTarget::CaseID->value => $this->t('CaseID'), + ], + ], + + ArchiveSettings::ARCHIVE_TARGET_CASE => [ + ArchiveTargetCase::NAME => [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Case ID'), + '#default_value' => $settings->archiveTargetCase?->caseId, + + '#states' => [ + 'visible' => [ + ':input[name="' . ArchiveSettings::NAME . '][' . ArchiveSettings::ARCHIVE_TARGET_CASE . '][' . ArchiveTargetCase::NAME . '"]' => [ + 'value' => ArchiveTarget::CaseID->value, + ], + ], + ], + ], + ], + ]; + + return parent::buildConfigurationForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + // @todo Validate something? + parent::validateConfigurationForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + foreach ([ + ArchiveSettings::NAME, + ] as $name) { + $this->configuration[$name] = $form_state->getValue($name); + } + } + + /** + * {@inheritdoc} + */ + public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE) { + // Run only when submission is completed. + // @todo Run on update? + if (!$webform_submission->isCompleted()) { + return; + } + + $this->helper->createJob($webform_submission, $this); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function getSummary() { + $settings = $this->settingsService->getArchiveSettings(); + + $build = [ + 'info' => [ + '#prefix' => '
', + '#suffix' => '
', + ], + ]; + + return $build; + } + + /** + * Get attachment elements. + * + * @phpstan-return array + */ + private function getAttachmentElements(): array { + $elements = $this->getWebform()->getElementsDecodedAndFlattened(); + + $elementTypes = [ + 'webform_entity_print_attachment:pdf', + 'os2forms_attachment', + ]; + $elements = array_filter( + $elements, + static fn(array $element) => in_array($element['#type'], $elementTypes, TRUE) + ); + + return array_map(static fn(array $element) => $element['#title'], $elements); + } + +} diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..c0ca0af --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,78 @@ +config = $configFactory->get(self::CONFIG_NAME); + } + + /** + * Get F2 API settings. + */ + public function getF2ApiSettings(): F2ApiSettings { + return new F2ApiSettings($this->getValue(F2ApiSettings::NAME)); + } + + /** + * Get general settings. + */ + public function getGeneralSettings(): GeneralSettings { + return new GeneralSettings($this->getValue(GeneralSettings::NAME)); + } + + /** + * Get handler settings. + */ + public function getArchiveSettings(array $values = []): ArchiveSettings { + return (new ArchiveSettings($this->getValue(ArchiveSettings::NAME))) + ->apply($values); + } + + /** + * Get handler settings. + * + * The settings are the global settings with handler specific settings on top. + */ + public function getHandlerSettings(WebformHandlerF2 $handler): HandlerSettings { + $handlerSettings = $handler->getSettings(); + + return new HandlerSettings([ + HandlerSettings::HANDLER_ID => $handler->gethandlerId(), + HandlerSettings::ARCHIVE => $this->getArchiveSettings($handlerSettings[ArchiveSettings::NAME] ?? []), + ]); + } + + /** + * Get settings value. + * + * @return array + * The settings values. + */ + private function getValue(string $section): array { + $values = $this->config->get($section); + + return is_array($values) ? $values : []; + } + +} diff --git a/src/Settings/AbstractSettings.php b/src/Settings/AbstractSettings.php new file mode 100644 index 0000000..5255d0b --- /dev/null +++ b/src/Settings/AbstractSettings.php @@ -0,0 +1,173 @@ + + * $settingsProperties = [ + * 'items' => SomeNestedSettings::class, + * ]; + * + * + * @var array + */ + protected static array $settingsProperties = []; + + /** + * List properties. + * + * Map from name to AbstractSettings type, e.g. + * + * + * $listProperties = [ + * 'items' => SomeNestedSettings::class, + * ]; + * + * + * @var array + */ + protected static array $listProperties = []; + + /** + * Enum properties. + * + * Map from name to Backed enum type, e.g. + * + * + * $enumProperties = [ + * 'type' => MyType::class, + * ]; + * + * + * @var array + */ + protected static array $enumProperties = []; + + /** + * Nullable properties. + * + * List of properties that must be set to null if the value is a blank string. + * + * @var array + */ + protected static array $nullableProperties = []; + + /** + * The values. + * + * @var array + */ + protected array $values; + + /** + * Constructor. + * + * @param array $values + * The values. + * @param bool $throwExceptionOnMissingProperty + * If set, an exception is thrown when setting an undefined property. + * If not set, undefined properties are silently ignored. + */ + public function __construct(array $values, bool $throwExceptionOnMissingProperty = FALSE) { + $this->values = []; + $this->apply($values, $throwExceptionOnMissingProperty); + } + + /** + * Apply values to settings. + */ + public function apply(array $values, bool $throwExceptionOnMissingProperty = FALSE): static { + foreach (static::$listProperties as $property => $class) { + if (isset($values[$property]) && is_array($values[$property])) { + $values[$property] = array_map(static fn(array $vals) => new $class($vals), $values[$property]); + } + } + + foreach (static::$settingsProperties as $property => $class) { + if (isset($values[$property]) && is_array($values[$property])) { + $values[$property] = new $class($values[$property]); + } + } + + foreach (static::$enumProperties as $property => $class) { + if (isset($values[$property]) && is_scalar($values[$property])) { + $values[$property] = $class::tryFrom($values[$property]); + } + } + + foreach ($values as $key => $value) { + $name = self::kebab2camel($key); + if (!property_exists($this, $name)) { + if ($throwExceptionOnMissingProperty) { + throw new \RuntimeException( + $name !== $key + ? sprintf('Property "%s" ("%s") does not exist in class %s.', + $name, $key, static::class) + : sprintf('Property "%s" does not exist in class %s.', $name, + static::class) + ); + } + else { + continue; + } + } + if (isset(static::$nullableProperties[$key]) && empty(trim((string) $value))) { + $value = NULL; + } + $this->$name = $value; + $this->values[self::camel2kebab($name)] = $value; + } + + return $this; + } + + /** + * Convert settings to array. + */ + public function toArray(bool $recursive = TRUE): array { + $values = $this->values; + + if ($recursive) { + foreach ($values as &$value) { + if ($value instanceof self) { + $value = $value->toArray($recursive); + } + } + } + + return $values; + } + + /** + * {@inheritdoc} + */ + public function jsonSerialize(): array { + return $this->toArray(); + } + + /** + * Convert kebab_case to camelCase. + */ + public static function kebab2camel(string $value): string { + return lcfirst(str_replace('_', '', ucwords($value, '_'))); + } + + /** + * Convert camelCase to kebab_case. + * + * @see https://stackoverflow.com/a/40514305/2502647 + */ + public static function camel2kebab(string $value): string { + return strtolower((string) preg_replace('/(?<=\d)(?=[A-Za-z])|(?<=[A-Za-z])(?=\d)|(?<=[a-z])(?=[A-Z])/', '_', $value)); + } + +} diff --git a/src/Settings/ArchiveSettings.php b/src/Settings/ArchiveSettings.php new file mode 100644 index 0000000..e57ef06 --- /dev/null +++ b/src/Settings/ArchiveSettings.php @@ -0,0 +1,34 @@ + ArchiveTarget::class, + ]; + + protected static array $settingsProperties = [ + self::ARCHIVE_TARGET_CASE => ArchiveTargetCase::class, + ]; + + const string HANDLER_ID = 'handler_id'; + public string $handlerId; + + const string ATTACHMENT_ELEMENT = 'attachment_element'; + public ?string $attachmentElement = NULL; + + const string ARCHIVE_TARGET = 'archive_target'; + public ?ArchiveTarget $archiveTarget = NULL; + + const string ARCHIVE_TARGET_CASE = 'archive_target_case'; + public ?ArchiveTargetCase $archiveTargetCase = NULL; + +} diff --git a/src/Settings/ArchiveSettings/ArchiveTarget.php b/src/Settings/ArchiveSettings/ArchiveTarget.php new file mode 100644 index 0000000..1aa65b1 --- /dev/null +++ b/src/Settings/ArchiveSettings/ArchiveTarget.php @@ -0,0 +1,10 @@ + ArchiveSettings::class, + ]; + + const string HANDLER_ID = 'handler_id'; + public string $handlerId; + + const string ARCHIVE = 'archive'; + public ?ArchiveSettings $archive = NULL; + +} From ecadf04db1f06d4748c0673fbbae0ef67540ba2f Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Wed, 12 Aug 2026 12:53:29 +0200 Subject: [PATCH 3/9] os2forms_f2 --- README.md | 6 + composer.json | 1 + os2forms_f2.services.yml | 3 +- src/Helper/F2Helper.php | 57 +++++++++ src/Helper/WebformHelperF2.php | 111 +++++++++++------- src/Model/Attachment.php | 29 +++++ .../WebformHandler/WebformHandlerF2.php | 69 ++++++++++- .../ArchiveSettings/ArchiveTargetCase.php | 6 +- 8 files changed, 231 insertions(+), 51 deletions(-) create mode 100644 src/Helper/F2Helper.php create mode 100644 src/Model/Attachment.php diff --git a/README.md b/README.md index 8537ebd..8a1c2ce 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ # OS2Forms: F2 integration + +``` shell +drush pm:install os2forms_f2 +``` + +Go to `/admin/os2forms_f2/settings` and define settings. diff --git a/composer.json b/composer.json index 8c574c2..41ed126 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,7 @@ "license": "MIT", "type": "drupal-module", "require": { + "itk-dev/drupal_psr6_cache": "^1.1", "itk-dev/f2-api-client": "dev-f2-api-client as 1.0.0", "os2forms/os2forms": "^5.0" }, diff --git a/os2forms_f2.services.yml b/os2forms_f2.services.yml index fc1aa0c..f4e0bf6 100644 --- a/os2forms_f2.services.yml +++ b/os2forms_f2.services.yml @@ -10,7 +10,8 @@ services: parent: logger.channel_base arguments: ["webform_submission"] - Drupal\os2forms_f2\Helper\WebformHelperF2: + Drupal\os2forms_f2\Helper\F2Helper: + Drupal\os2forms_f2\Helper\WebformHelperF2: Drupal\os2forms_f2\Settings: diff --git a/src/Helper/F2Helper.php b/src/Helper/F2Helper.php new file mode 100644 index 0000000..0ba5e77 --- /dev/null +++ b/src/Helper/F2Helper.php @@ -0,0 +1,57 @@ +client()->caseById($id); + } + + /** + * @return CaseFile[] + */ + public function caseList(string $q, int $count = 10): array { + return $this->client()->caseSearch($q, $count); + } + + private ?ApiClient $apiClient = NULL; + + /** + * Get API client. + */ + public function client(): ApiClient { + if (NULL === $this->apiClient) { + $settings = $this->settings->getF2ApiSettings(); + $this->apiClient = new ApiClient([ + 'api_uri' => $settings->uri, + 'api_username' => $settings->username, + 'api_secret' => $settings->secret, + 'f2_username' => $settings->f2Username, + 'cache_item_pool' => $this->cacheItemPool, + ]); + } + + return $this->apiClient; + } + +} diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php index 0c39502..09f8fb9 100644 --- a/src/Helper/WebformHelperF2.php +++ b/src/Helper/WebformHelperF2.php @@ -2,6 +2,7 @@ namespace Drupal\os2forms_f2\Helper; +use ArchiveSettings\ArchiveTarget; use Drupal\advancedqueue\Entity\QueueInterface; use Drupal\advancedqueue\Job; use Drupal\advancedqueue\JobResult; @@ -9,17 +10,19 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\Core\Render\ElementInfoManager; +use Drupal\os2forms_f2\Exception\InvalidAttachmentElementException; use Drupal\os2forms_f2\Exception\RuntimeException; use Drupal\os2forms_f2\Exception\SubmissionNotFoundException; +use Drupal\os2forms_f2\Model\Attachment; use Drupal\os2forms_f2\Plugin\AdvancedQueue\JobType\F2; use Drupal\os2forms_f2\Plugin\WebformHandler\WebformHandlerF2; use Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\ArchiveSettings; use Drupal\os2forms_f2\Settings\HandlerSettings; -use Drupal\webform\WebformInterface; use Drupal\webform\WebformSubmissionInterface; use Drupal\webform\WebformSubmissionStorageInterface; use Drupal\webform\WebformTokenManagerInterface; +use Drupal\webform_attachment\Element\WebformAttachmentBase; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -30,16 +33,6 @@ final class WebformHelperF2 implements LoggerInterface { use LoggerTrait; - private const string PAYLOAD_KEY = 'os2forms_f2'; - private const string PAYLOAD_STATE = 'state'; - private const string STATE_UPLOAD_FILES = 'upload_files'; - - private const string PAYLOAD_FILES = 'files'; - private const string STATE_CHECK_FILES = 'check_files'; - - private const string PAYLOAD_FILES_DELIVERED = 'files_delivered'; - private const string STATE_SEND_DISTRIBUTION_OBJECT = 'send_distribution_object'; - private const string PDF_MIME_TYPE = 'application/pdf'; /** @@ -62,6 +55,7 @@ final class WebformHelperF2 implements LoggerInterface { public function __construct( EntityTypeManagerInterface $entityTypeManager, private readonly Settings $settings, + private readonly F2Helper $f2, #[Autowire(service: 'plugin.manager.element_info')] private readonly ElementInfoManager $elementInfoManager, #[Autowire(service: 'webform.token_manager')] @@ -87,29 +81,6 @@ public function loadSubmission(int $id): ?WebformSubmissionInterface { return $submission; } - /** - * Load submission IDs for a webform. - */ - public function loadSubmissionIds(WebformInterface $webform): array { - return $this->webformSubmissionStorage->getQuery() - ->accessCheck() - ->condition('webform_id', $webform->id()) - ->sort('created', 'DESC') - ->sort('sid', 'DESC') - ->execute(); - } - - /** - * Load latest submission on a webform. - */ - public function loadLatestSubmission(WebformInterface $webform): ?WebformSubmissionInterface { - $submissionIds = $this->loadSubmissionIds($webform); - - $id = reset($submissionIds); - - return $id ? $this->loadSubmission($id) : NULL; - } - /** * Load queue. */ @@ -212,26 +183,82 @@ public function processJob(Job $job): JobResult { $context['webform_submission'] = $submission; $handlerSettings = new HandlerSettings($payload['handlerSettings']); - throw new \RuntimeException(__METHOD__ . 'not implemented'); - - return JobResult::success(); + $target = $handlerSettings->archive?->archiveTarget; + return match ($target) { + ArchiveTarget::CaseID => $this->archiveOnCase($submission, $handlerSettings), + default => throw new RuntimeException(sprintf('Invalid archive target: %s', $target->name)), + }; } - catch (\Exception $e) { + catch (\Exception $exception) { $this->error('Error: @message', $context + [ - '@message' => $e->getMessage(), - 'exception' => $e, + '@message' => $exception->getMessage(), + 'exception' => $exception, ]); - return JobResult::failure($e->getMessage()); + return JobResult::failure($exception->getMessage()); } } /** * Replace tokens in handler settings supporting tokens. */ - private function replaceTokens(HandlerSettings $handlerSettings, WebformSubmissionInterface $submission): ArchiveSettings { + private function replaceTokens(HandlerSettings $handlerSettings, WebformSubmissionInterface $submission): HandlerSettings { // @todo Should we clone the settings before making changes? return $handlerSettings; } + /** + * + */ + private function archiveOnCase(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult { + $caseId = $handlerSettings->archive?->archiveTargetCase?->caseId; + if (NULL === $caseId) { + throw new RuntimeException('Cannot get case ID'); + } + $case = $this->f2->getCaseById($caseId); + $attachment = $this->getAttachment($submission, $handlerSettings); + $this->f2->client()->documentCreate(); + throw new \RuntimeException(__METHOD__); + } + + /** + * Get main document. + * + * @throws InvalidAttachmentElementException + * + * @see WebformAttachmentController::download() + */ + protected function getAttachment(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): ?Attachment { + // Lifted from Drupal\webform_attachment\Controller\WebformAttachmentController::download. + $element = $handlerSettings->archive->attachmentElement; + if (NULL === $element) { + throw new InvalidAttachmentElementException('Cannot get attachment element'); + } + $element = $submission->getWebform()->getElement($element) ?: []; + if (!isset($element['#type'])) { + throw new InvalidAttachmentElementException(sprintf('Cannot get attachment element %s', $element)); + } + [$type] = explode(':', $element['#type']); + $instance = $this->elementInfoManager->createInstance($type); + + if (!$instance instanceof WebformAttachmentBase) { + throw new InvalidAttachmentElementException(sprintf('Attachment element must be an instance of %s. Found %s.', WebformAttachmentBase::class, $instance::class)); + } + + $fileName = $instance::getFileName($element, $submission); + $mimeType = $instance::getFileMimeType($element, $submission); + + if (self::PDF_MIME_TYPE !== $mimeType) { + throw new InvalidAttachmentElementException(sprintf('The attachment element must be a PDF file (%s); got %s.', self::PDF_MIME_TYPE, $mimeType)); + } + + $content = $instance::getFileContent($element, $submission); + + return new Attachment( + $content, + $mimeType, + $fileName + ); + } + } diff --git a/src/Model/Attachment.php b/src/Model/Attachment.php new file mode 100644 index 0000000..efc7785 --- /dev/null +++ b/src/Model/Attachment.php @@ -0,0 +1,29 @@ +mimeType; + } + +} diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php index ebbb38d..1f035a2 100644 --- a/src/Plugin/WebformHandler/WebformHandlerF2.php +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -4,6 +4,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\os2forms_f2\Helper\F2Helper; use Drupal\os2forms_f2\Helper\WebformHelperF2; use Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\ArchiveSettings; @@ -42,6 +43,11 @@ final class WebformHandlerF2 extends WebformHandlerBase { */ private WebformHelperF2 $helper; + /** + * The F2 helper. + */ + private F2Helper $f2; + /** * {@inheritdoc} */ @@ -50,6 +56,7 @@ public static function create(ContainerInterface $container, array $configuratio $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); $instance->settingsService = $container->get(Settings::class); $instance->helper = $container->get(WebformHelperF2::class); + $instance->f2 = $container->get(F2Helper::class); return $instance; } @@ -66,7 +73,7 @@ public function getOffCanvasWidth(): string { */ #[\Override] public function buildConfigurationForm(array $form, FormStateInterface $form_state) { - $settings = $this->settingsService->getArchiveSettings((array) ($this->getSettings()[ArchiveSettings::NAME] ?? NULL)); + $settings = $this->settingsService->getArchiveSettings((array) ($this->getSetting(ArchiveSettings::NAME))); $form[ArchiveSettings::NAME] = [ ArchiveSettings::ATTACHMENT_ELEMENT => [ @@ -87,8 +94,8 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta ], ], - ArchiveSettings::ARCHIVE_TARGET_CASE => [ - ArchiveTargetCase::NAME => [ + ArchiveTargetCase::NAME => [ + ArchiveTargetCase::CASE_ID => [ '#type' => 'textfield', '#required' => TRUE, '#title' => $this->t('Case ID'), @@ -96,7 +103,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#states' => [ 'visible' => [ - ':input[name="' . ArchiveSettings::NAME . '][' . ArchiveSettings::ARCHIVE_TARGET_CASE . '][' . ArchiveTargetCase::NAME . '"]' => [ + ':input[name="settings[' . ArchiveSettings::NAME . '][' . ArchiveTargetCase::NAME . ']"]' => [ 'value' => ArchiveTarget::CaseID->value, ], ], @@ -105,6 +112,24 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta ], ]; + if (ArchiveTarget::CaseID === $settings->archiveTarget) { + $caseId = $settings->archiveTargetCase?->caseId; + if (NULL !== $caseId) { + try { + $case = $this->f2->getCaseById($caseId); + $form[ArchiveSettings::NAME][ArchiveTargetCase::NAME]['case_info'] = [ + '#type' => 'details', + '#open' => TRUE, + '#title' => $this->t('Case'), + '#markup' => $case, + ]; + } + catch (\Throwable $e) { + // Ignore all errors. + } + } + } + return parent::buildConfigurationForm($form, $form_state); } @@ -112,8 +137,29 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta * {@inheritdoc} */ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { - // @todo Validate something? parent::validateConfigurationForm($form, $form_state); + + $target = $form_state->getValue([ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET]); + if (ArchiveTarget::CaseID->value === $target) { + $key = [ArchiveSettings::NAME, ArchiveTargetCase::NAME, ArchiveTargetCase::CASE_ID]; + $caseId = trim((string) $form_state->getValue($key)); + $caseId = filter_var($caseId, FILTER_SANITIZE_NUMBER_INT); + if (FALSE === $caseId) { + $form_state->setErrorByName(implode('][', $key), t('Missing or invalid case ID.')); + } + else { + try { + $this->f2->getCaseById($caseId); + } + catch (\Throwable $throwable) { + $form_state->setErrorByName(implode('][', $key), t('Cannot get case by ID @case_id (@message).', [ + '@case_id' => $caseId, + '@message' => $throwable->getMessage(), + ])); + } + } + } + } /** @@ -146,7 +192,7 @@ public function postSave(WebformSubmissionInterface $webform_submission, $update */ #[\Override] public function getSummary() { - $settings = $this->settingsService->getArchiveSettings(); + $settings = $this->settingsService->getHandlerSettings($this); $build = [ 'info' => [ @@ -155,6 +201,17 @@ public function getSummary() { ], ]; + switch ($settings->archive?->archiveTarget) { + case ArchiveTarget::CaseID: + $caseId = $settings->archive->archiveTargetCase?->caseId; + if ($caseId) { + $build['info'][ArchiveTargetCase::NAME] = [ + '#markup' => $this->t('Archive on case @case_id', ['@case_id' => $caseId]), + ]; + } + break; + } + return $build; } diff --git a/src/Settings/ArchiveSettings/ArchiveTargetCase.php b/src/Settings/ArchiveSettings/ArchiveTargetCase.php index 4079cc3..b49f27c 100644 --- a/src/Settings/ArchiveSettings/ArchiveTargetCase.php +++ b/src/Settings/ArchiveSettings/ArchiveTargetCase.php @@ -3,13 +3,15 @@ namespace Drupal\os2forms_f2\Settings\ArchiveSettings; use Drupal\os2forms_f2\Settings\AbstractSettings; +use Drupal\os2forms_f2\Settings\ArchiveSettings; /** * Settings for ArchiveTargetCase. */ final class ArchiveTargetCase extends AbstractSettings { - const string NAME = 'archive_target_case'; + const string NAME = ArchiveSettings::ARCHIVE_TARGET_CASE; + const string CASE_ID = 'case_id'; - public ?string $caseId = NULL; + public ?int $caseId = NULL; } From 814f506ae9574437d9cceec4881d267955abe4e5 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Wed, 12 Aug 2026 13:43:23 +0200 Subject: [PATCH 4/9] =?UTF-8?q?"case"=20=E2=86=92=20"matter"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Helper/F2Helper.php | 15 ----- src/Helper/WebformHelperF2.php | 35 ++++++++--- src/Model/Attachment.php | 1 - .../WebformHandler/WebformHandlerF2.php | 59 ++++++++++--------- src/Settings/ArchiveSettings.php | 8 +-- .../ArchiveSettings/ArchiveTarget.php | 2 +- .../ArchiveSettings/ArchiveTargetCase.php | 17 ------ .../ArchiveSettings/ArchiveTargetMatter.php | 17 ++++++ 8 files changed, 78 insertions(+), 76 deletions(-) delete mode 100644 src/Settings/ArchiveSettings/ArchiveTargetCase.php create mode 100644 src/Settings/ArchiveSettings/ArchiveTargetMatter.php diff --git a/src/Helper/F2Helper.php b/src/Helper/F2Helper.php index 0ba5e77..ff4db08 100644 --- a/src/Helper/F2Helper.php +++ b/src/Helper/F2Helper.php @@ -4,7 +4,6 @@ use Drupal\os2forms_f2\Settings; use ItkDev\F2ApiClient\ApiClient; -use ItkDev\F2ApiClient\Model\CaseFile; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -20,20 +19,6 @@ public function __construct( ) { } - /** - * - */ - public function getCaseById(int $id): CaseFile { - return $this->client()->caseById($id); - } - - /** - * @return CaseFile[] - */ - public function caseList(string $q, int $count = 10): array { - return $this->client()->caseSearch($q, $count); - } - private ?ApiClient $apiClient = NULL; /** diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php index 09f8fb9..bb9a51b 100644 --- a/src/Helper/WebformHelperF2.php +++ b/src/Helper/WebformHelperF2.php @@ -2,12 +2,12 @@ namespace Drupal\os2forms_f2\Helper; -use ArchiveSettings\ArchiveTarget; 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; use Drupal\Core\Render\ElementInfoManager; use Drupal\os2forms_f2\Exception\InvalidAttachmentElementException; @@ -18,11 +18,13 @@ use Drupal\os2forms_f2\Plugin\WebformHandler\WebformHandlerF2; 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 Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -56,6 +58,7 @@ public function __construct( EntityTypeManagerInterface $entityTypeManager, private readonly Settings $settings, private readonly F2Helper $f2, + private readonly FileSystemInterface $fileSystem, #[Autowire(service: 'plugin.manager.element_info')] private readonly ElementInfoManager $elementInfoManager, #[Autowire(service: 'webform.token_manager')] @@ -185,7 +188,7 @@ public function processJob(Job $job): JobResult { $target = $handlerSettings->archive?->archiveTarget; return match ($target) { - ArchiveTarget::CaseID => $this->archiveOnCase($submission, $handlerSettings), + ArchiveTarget::MatterID => $this->archiveOnMatter($submission, $handlerSettings), default => throw new RuntimeException(sprintf('Invalid archive target: %s', $target->name)), }; } @@ -210,15 +213,29 @@ private function replaceTokens(HandlerSettings $handlerSettings, WebformSubmissi /** * */ - private function archiveOnCase(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult { - $caseId = $handlerSettings->archive?->archiveTargetCase?->caseId; - if (NULL === $caseId) { - throw new RuntimeException('Cannot get case ID'); + private function archiveOnMatter(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult { + $matterId = $handlerSettings->archive?->archiveTargetMatter?->matterId; + if (NULL === $matterId) { + throw new RuntimeException('Cannot get matter ID'); } - $case = $this->f2->getCaseById($caseId); + $matter = $this->f2->client()->matterById($matterId); $attachment = $this->getAttachment($submission, $handlerSettings); - $this->f2->client()->documentCreate(); - throw new \RuntimeException(__METHOD__); + $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 = sprintf('@todo %s (from %s)', $attachment->filename, $submission->label()); + $document = $this->f2->client()->documentCreate($document, $filePath, $matter); + $message = sprintf('Document %s created on matter %s', $document, $matter); + } + finally { + if (isset($filePath) && file_exists($filePath)) { + unlink($filePath); + } + } + + return JobResult::success($message); } /** diff --git a/src/Model/Attachment.php b/src/Model/Attachment.php index efc7785..e1064bf 100644 --- a/src/Model/Attachment.php +++ b/src/Model/Attachment.php @@ -6,7 +6,6 @@ * The Document class. */ final readonly class Attachment { - public const string FORMAT_NAME_PDF = 'PDF'; public const string MIME_TYPE_PDF = 'application/pdf'; /** diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php index 1f035a2..c13d96e 100644 --- a/src/Plugin/WebformHandler/WebformHandlerF2.php +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -9,7 +9,7 @@ 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; @@ -90,21 +90,21 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#title' => $this->t('Archive target'), '#default_value' => $settings->archiveTarget?->value, '#options' => [ - ArchiveTarget::CaseID->value => $this->t('CaseID'), + ArchiveTarget::MatterID->value => $this->t('Matter ID'), ], ], - ArchiveTargetCase::NAME => [ - ArchiveTargetCase::CASE_ID => [ + ArchiveTargetMatter::NAME => [ + ArchiveTargetMatter::MATTER_ID => [ '#type' => 'textfield', '#required' => TRUE, - '#title' => $this->t('Case ID'), - '#default_value' => $settings->archiveTargetCase?->caseId, + '#title' => $this->t('Matter ID'), + '#default_value' => $settings->archiveTargetMatter?->matterId, '#states' => [ 'visible' => [ - ':input[name="settings[' . ArchiveSettings::NAME . '][' . ArchiveTargetCase::NAME . ']"]' => [ - 'value' => ArchiveTarget::CaseID->value, + ':input[name="settings[' . ArchiveSettings::NAME . '][' . ArchiveTargetMatter::NAME . ']"]' => [ + 'value' => ArchiveTarget::MatterID->value, ], ], ], @@ -112,16 +112,17 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta ], ]; - if (ArchiveTarget::CaseID === $settings->archiveTarget) { - $caseId = $settings->archiveTargetCase?->caseId; - if (NULL !== $caseId) { + if (ArchiveTarget::MatterID === $settings->archiveTarget) { + $matterId = $settings->archiveTargetMatter?->matterId; + if (NULL !== $matterId) { try { - $case = $this->f2->getCaseById($caseId); - $form[ArchiveSettings::NAME][ArchiveTargetCase::NAME]['case_info'] = [ + $matter = $this->f2->client()->matterById($matterId); + + $form[ArchiveSettings::NAME][ArchiveTargetMatter::NAME]['details'] = [ '#type' => 'details', '#open' => TRUE, - '#title' => $this->t('Case'), - '#markup' => $case, + '#title' => $this->t('Matter'), + '#markup' => $matter, ]; } catch (\Throwable $e) { @@ -140,20 +141,20 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form parent::validateConfigurationForm($form, $form_state); $target = $form_state->getValue([ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET]); - if (ArchiveTarget::CaseID->value === $target) { - $key = [ArchiveSettings::NAME, ArchiveTargetCase::NAME, ArchiveTargetCase::CASE_ID]; - $caseId = trim((string) $form_state->getValue($key)); - $caseId = filter_var($caseId, FILTER_SANITIZE_NUMBER_INT); - if (FALSE === $caseId) { - $form_state->setErrorByName(implode('][', $key), t('Missing or invalid case ID.')); + if (ArchiveTarget::MatterID->value === $target) { + $key = [ArchiveSettings::NAME, ArchiveTargetMatter::NAME, ArchiveTargetMatter::MATTER_ID]; + $matterId = trim((string) $form_state->getValue($key)); + $matterId = filter_var($matterId, FILTER_SANITIZE_NUMBER_INT); + if (FALSE === $matterId) { + $form_state->setErrorByName(implode('][', $key), t('Missing or invalid matter ID.')); } else { try { - $this->f2->getCaseById($caseId); + $this->f2->client()->matterById($matterId); } catch (\Throwable $throwable) { - $form_state->setErrorByName(implode('][', $key), t('Cannot get case by ID @case_id (@message).', [ - '@case_id' => $caseId, + $form_state->setErrorByName(implode('][', $key), t('Cannot get matter by ID @matter_id (@message).', [ + '@matter_id' => $matterId, '@message' => $throwable->getMessage(), ])); } @@ -202,11 +203,11 @@ public function getSummary() { ]; switch ($settings->archive?->archiveTarget) { - case ArchiveTarget::CaseID: - $caseId = $settings->archive->archiveTargetCase?->caseId; - if ($caseId) { - $build['info'][ArchiveTargetCase::NAME] = [ - '#markup' => $this->t('Archive on case @case_id', ['@case_id' => $caseId]), + 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]), ]; } break; diff --git a/src/Settings/ArchiveSettings.php b/src/Settings/ArchiveSettings.php index e57ef06..a2dd731 100644 --- a/src/Settings/ArchiveSettings.php +++ b/src/Settings/ArchiveSettings.php @@ -3,7 +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; /** * Webform archive settings. @@ -16,7 +16,7 @@ final class ArchiveSettings extends AbstractSettings { ]; protected static array $settingsProperties = [ - self::ARCHIVE_TARGET_CASE => ArchiveTargetCase::class, + self::ARCHIVE_TARGET_MATTER => ArchiveTargetMatter::class, ]; const string HANDLER_ID = 'handler_id'; @@ -28,7 +28,7 @@ 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 1aa65b1..374013e 100644 --- a/src/Settings/ArchiveSettings/ArchiveTarget.php +++ b/src/Settings/ArchiveSettings/ArchiveTarget.php @@ -6,5 +6,5 @@ * Archive targets. */ enum ArchiveTarget: string { - case CaseID = 'case_id'; + case MatterID = 'matter_id'; } diff --git a/src/Settings/ArchiveSettings/ArchiveTargetCase.php b/src/Settings/ArchiveSettings/ArchiveTargetCase.php deleted file mode 100644 index b49f27c..0000000 --- a/src/Settings/ArchiveSettings/ArchiveTargetCase.php +++ /dev/null @@ -1,17 +0,0 @@ - Date: Wed, 12 Aug 2026 15:11:19 +0200 Subject: [PATCH 5/9] Added document title --- src/Helper/WebformHelperF2.php | 5 ++++- src/Plugin/WebformHandler/WebformHandlerF2.php | 8 ++++++++ src/Settings/ArchiveSettings.php | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php index bb9a51b..931233f 100644 --- a/src/Helper/WebformHelperF2.php +++ b/src/Helper/WebformHelperF2.php @@ -185,6 +185,7 @@ public function processJob(Job $job): JobResult { $context['webform_submission'] = $submission; $handlerSettings = new HandlerSettings($payload['handlerSettings']); + $this->replaceTokens($handlerSettings, $submission); $target = $handlerSettings->archive?->archiveTarget; return match ($target) { @@ -207,6 +208,8 @@ public function processJob(Job $job): JobResult { */ 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); + return $handlerSettings; } @@ -225,7 +228,7 @@ private function archiveOnMatter(WebformSubmissionInterface $submission, Handler // 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 = sprintf('@todo %s (from %s)', $attachment->filename, $submission->label()); + $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); } diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php index c13d96e..f95cd5a 100644 --- a/src/Plugin/WebformHandler/WebformHandlerF2.php +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -84,6 +84,14 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#options' => $this->getAttachmentElements(), ], + ArchiveSettings::DOCUMENT_TITLE => [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Document title'), + '#default_value' => $settings->documentTitle, + '#description' => $this->t('The title of the document. Tokens can be used in the title, e.g. [webform_submission:label].'), + ], + ArchiveSettings::ARCHIVE_TARGET => [ '#type' => 'select', '#required' => TRUE, diff --git a/src/Settings/ArchiveSettings.php b/src/Settings/ArchiveSettings.php index a2dd731..68cf7cb 100644 --- a/src/Settings/ArchiveSettings.php +++ b/src/Settings/ArchiveSettings.php @@ -25,6 +25,9 @@ final class ArchiveSettings extends AbstractSettings { const string ATTACHMENT_ELEMENT = 'attachment_element'; public ?string $attachmentElement = NULL; + const string DOCUMENT_TITLE = 'document_title'; + public ?string $documentTitle = NULL; + const string ARCHIVE_TARGET = 'archive_target'; public ?ArchiveTarget $archiveTarget = NULL; From 491db5c1c66df78815de15e2446876b5ffaf7f83 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Wed, 12 Aug 2026 15:11:27 +0200 Subject: [PATCH 6/9] Updated documentation --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8a1c2ce..999451d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # OS2Forms: F2 integration -``` shell -drush pm:install os2forms_f2 -``` +1. Install the module -Go to `/admin/os2forms_f2/settings` and define settings. + ``` shell + drush pm:install os2forms_f2 + ``` + +2. Go to `/admin/os2forms_f2/settings` and define settings. +3. Add a "F2" handler to a webform. From 174c1136f7a80de8c209bc132bcdda7d0ba518a1 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 13 Aug 2026 15:50:58 +0200 Subject: [PATCH 7/9] Added missing dependency --- os2forms_f2.info.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/os2forms_f2.info.yml b/os2forms_f2.info.yml index a1efc23..5422217 100644 --- a/os2forms_f2.info.yml +++ b/os2forms_f2.info.yml @@ -4,6 +4,7 @@ description: "OS2Forms: F2 integration" package: OS2Forms core_version_requirement: ^10 || ^11 dependencies: + - drupal_psr6_cache:drupal_psr6_cache - os2forms:os2forms configure: os2forms_f2.admin.settings From 0d7cc2a4d0adfa68fb14cda5d8620b0078140bfb Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 13 Aug 2026 17:13:32 +0200 Subject: [PATCH 8/9] Improved settings form --- os2forms_f2.links.menu.yml | 5 +++ src/Form/SettingsForm.php | 40 +++++++++++++++++-- src/Helper/F2Helper.php | 7 ++++ .../WebformHandler/WebformHandlerF2.php | 7 +++- 4 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 os2forms_f2.links.menu.yml diff --git a/os2forms_f2.links.menu.yml b/os2forms_f2.links.menu.yml new file mode 100644 index 0000000..4b72a0c --- /dev/null +++ b/os2forms_f2.links.menu.yml @@ -0,0 +1,5 @@ +os2forms_f2.admin.settings: + title: OS2Forms F2 + description: Configure the OS2Forms F2 module + parent: system.admin_config_system + route_name: os2forms_f2.admin.settings diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index c126c7a..0371268 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -13,7 +13,7 @@ use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\os2forms_f2\Helper\F2Helper; use Drupal\os2forms_f2\Settings; use Drupal\os2forms_f2\Settings\F2ApiSettings; use Drupal\os2forms_f2\Settings\GeneralSettings; @@ -25,6 +25,8 @@ final class SettingsForm extends ConfigFormBase { use StringTranslationTrait; use AutowireTrait; + private const string ACTION_PING_API = 'action_ping_api'; + /** * The queue storage. * @@ -40,6 +42,7 @@ public function __construct( TypedConfigManagerInterface $typedConfigManager, EntityTypeManagerInterface $entityTypeManager, private readonly Settings $settings, + private readonly F2Helper $f2, ) { parent::__construct($config_factory, $typedConfigManager); $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); @@ -64,6 +67,8 @@ protected function getEditableConfigNames(): array { */ #[\Override] public function buildForm(array $form, FormStateInterface $form_state): array { + $form = parent::buildForm($form, $form_state); + $form[F2ApiSettings::NAME] = [ '#type' => 'fieldset', '#title' => $this->t('F2 API'), @@ -76,7 +81,22 @@ public function buildForm(array $form, FormStateInterface $form_state): array { '#tree' => TRUE, ] + $this->buildFormGeneral(); - return parent::buildForm($form, $form_state); + $form[self::ACTION_PING_API] = [ + '#type' => 'container', + '#weight' => 10000, + + self::ACTION_PING_API => [ + '#type' => 'submit', + '#name' => self::ACTION_PING_API, + '#value' => $this->t('Ping API'), + ], + + 'message' => [ + '#markup' => $this->t('Note: Pinging the API will use saved config.'), + ], + ]; + + return $form; } /** @@ -160,9 +180,10 @@ private function buildFormGeneral(): array { */ #[\Override] public function validateForm(array &$form, FormStateInterface $form_state): void { - $setError = static fn(string|array $path, TranslatableMarkup $message) => $form_state->setErrorByName(implode('][', (array) $path), $message); + if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) { + return; + } - // @todo Validate something? parent::validateForm($form, $form_state); } @@ -171,6 +192,17 @@ public function validateForm(array &$form, FormStateInterface $form_state): void */ #[\Override] public function submitForm(array &$form, FormStateInterface $form_state): void { + if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) { + try { + $this->f2->pingApi(); + $this->messenger()->addStatus($this->t('Pinged API successfully.')); + } + catch (\Throwable $t) { + $this->messenger()->addError($this->t('Pinging API failed: @message', ['@message' => $t->getMessage()])); + } + return; + } + $config = $this->config(Settings::CONFIG_NAME); foreach ([ F2ApiSettings::NAME, diff --git a/src/Helper/F2Helper.php b/src/Helper/F2Helper.php index ff4db08..16b54c6 100644 --- a/src/Helper/F2Helper.php +++ b/src/Helper/F2Helper.php @@ -39,4 +39,11 @@ public function client(): ApiClient { return $this->apiClient; } + /** + * + */ + public function pingApi(): void { + $this->client()->matterSearch('ping'); + } + } diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php index f95cd5a..5aee4ba 100644 --- a/src/Plugin/WebformHandler/WebformHandlerF2.php +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -4,6 +4,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\os2forms_f2\Helper\F2Helper; use Drupal\os2forms_f2\Helper\WebformHelperF2; use Drupal\os2forms_f2\Settings; @@ -148,20 +149,22 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { parent::validateConfigurationForm($form, $form_state); + $setError = static fn(string|array $path, TranslatableMarkup $message) => $form_state->setErrorByName(implode('][', (array) $path), $message); + $target = $form_state->getValue([ArchiveSettings::NAME, ArchiveSettings::ARCHIVE_TARGET]); if (ArchiveTarget::MatterID->value === $target) { $key = [ArchiveSettings::NAME, ArchiveTargetMatter::NAME, ArchiveTargetMatter::MATTER_ID]; $matterId = trim((string) $form_state->getValue($key)); $matterId = filter_var($matterId, FILTER_SANITIZE_NUMBER_INT); if (FALSE === $matterId) { - $form_state->setErrorByName(implode('][', $key), t('Missing or invalid matter ID.')); + $setError($key, t('Missing or invalid matter ID.')); } else { try { $this->f2->client()->matterById($matterId); } catch (\Throwable $throwable) { - $form_state->setErrorByName(implode('][', $key), t('Cannot get matter by ID @matter_id (@message).', [ + $setError($key, t('Cannot get matter by ID @matter_id (@message).', [ '@matter_id' => $matterId, '@message' => $throwable->getMessage(), ])); From 914967844c563fd932c5436bb69c3c1903d849c4 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 13 Aug 2026 17:14:04 +0200 Subject: [PATCH 9/9] Fixed matter ID validation --- src/Plugin/WebformHandler/WebformHandlerF2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugin/WebformHandler/WebformHandlerF2.php b/src/Plugin/WebformHandler/WebformHandlerF2.php index 5aee4ba..1f0d03b 100644 --- a/src/Plugin/WebformHandler/WebformHandlerF2.php +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -155,7 +155,7 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form if (ArchiveTarget::MatterID->value === $target) { $key = [ArchiveSettings::NAME, ArchiveTargetMatter::NAME, ArchiveTargetMatter::MATTER_ID]; $matterId = trim((string) $form_state->getValue($key)); - $matterId = filter_var($matterId, FILTER_SANITIZE_NUMBER_INT); + $matterId = filter_var($matterId, FILTER_VALIDATE_INT); if (FALSE === $matterId) { $setError($key, t('Missing or invalid matter ID.')); }