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/README.md b/README.md index 8537ebd..999451d 100644 --- a/README.md +++ b/README.md @@ -1 +1,10 @@ # OS2Forms: F2 integration + +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. 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..41ed126 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "os2forms/os2forms_f2", + "description": "OS2Forms: F2 integration", + "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" + }, + "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/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 new file mode 100644 index 0000000..5422217 --- /dev/null +++ b/os2forms_f2.info.yml @@ -0,0 +1,16 @@ +name: "os2forms_f2" +type: module +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 + +"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.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/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..f4e0bf6 --- /dev/null +++ b/os2forms_f2.services.yml @@ -0,0 +1,17 @@ +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\F2Helper: + + Drupal\os2forms_f2\Helper\WebformHelperF2: + + Drupal\os2forms_f2\Settings: 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 <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 = parent::buildForm($form, $form_state); + + $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(); + + $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; + } + + /** + * 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 { + if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) { + return; + } + + parent::validateForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + #[\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, + GeneralSettings::NAME, + ] as $name) { + $config->set($name, $form_state->getValue($name)); + } + $config->save(); + + parent::submitForm($form, $form_state); + } + +} diff --git a/src/Helper/F2Helper.php b/src/Helper/F2Helper.php new file mode 100644 index 0000000..16b54c6 --- /dev/null +++ b/src/Helper/F2Helper.php @@ -0,0 +1,49 @@ +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; + } + + /** + * + */ + public function pingApi(): void { + $this->client()->matterSearch('ping'); + } + +} diff --git a/src/Helper/WebformHelperF2.php b/src/Helper/WebformHelperF2.php new file mode 100644 index 0000000..931233f --- /dev/null +++ b/src/Helper/WebformHelperF2.php @@ -0,0 +1,284 @@ +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 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']); + $this->replaceTokens($handlerSettings, $submission); + + $target = $handlerSettings->archive?->archiveTarget; + return match ($target) { + ArchiveTarget::MatterID => $this->archiveOnMatter($submission, $handlerSettings), + default => throw new RuntimeException(sprintf('Invalid archive target: %s', $target->name)), + }; + } + catch (\Exception $exception) { + $this->error('Error: @message', $context + [ + '@message' => $exception->getMessage(), + 'exception' => $exception, + ]); + + return JobResult::failure($exception->getMessage()); + } + } + + /** + * 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); + + return $handlerSettings; + } + + /** + * + */ + private function archiveOnMatter(WebformSubmissionInterface $submission, HandlerSettings $handlerSettings): JobResult { + $matterId = $handlerSettings->archive?->archiveTargetMatter?->matterId; + if (NULL === $matterId) { + throw new RuntimeException('Cannot get matter ID'); + } + $matter = $this->f2->client()->matterById($matterId); + $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); + } + finally { + if (isset($filePath) && file_exists($filePath)) { + unlink($filePath); + } + } + + return JobResult::success($message); + } + + /** + * 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..e1064bf --- /dev/null +++ b/src/Model/Attachment.php @@ -0,0 +1,28 @@ +mimeType; + } + +} 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..1f0d03b --- /dev/null +++ b/src/Plugin/WebformHandler/WebformHandlerF2.php @@ -0,0 +1,250 @@ +settingsService = $container->get(Settings::class); + $instance->helper = $container->get(WebformHelperF2::class); + $instance->f2 = $container->get(F2Helper::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->getSetting(ArchiveSettings::NAME))); + + $form[ArchiveSettings::NAME] = [ + ArchiveSettings::ATTACHMENT_ELEMENT => [ + '#type' => 'select', + '#required' => TRUE, + '#title' => $this->t('Attachment element'), + '#default_value' => $settings->attachmentElement, + '#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, + '#title' => $this->t('Archive target'), + '#default_value' => $settings->archiveTarget?->value, + '#options' => [ + ArchiveTarget::MatterID->value => $this->t('Matter ID'), + ], + ], + + ArchiveTargetMatter::NAME => [ + ArchiveTargetMatter::MATTER_ID => [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Matter ID'), + '#default_value' => $settings->archiveTargetMatter?->matterId, + + '#states' => [ + 'visible' => [ + ':input[name="settings[' . ArchiveSettings::NAME . '][' . ArchiveTargetMatter::NAME . ']"]' => [ + 'value' => ArchiveTarget::MatterID->value, + ], + ], + ], + ], + ], + ]; + + if (ArchiveTarget::MatterID === $settings->archiveTarget) { + $matterId = $settings->archiveTargetMatter?->matterId; + if (NULL !== $matterId) { + try { + $matter = $this->f2->client()->matterById($matterId); + + $form[ArchiveSettings::NAME][ArchiveTargetMatter::NAME]['details'] = [ + '#type' => 'details', + '#open' => TRUE, + '#title' => $this->t('Matter'), + '#markup' => $matter, + ]; + } + catch (\Throwable $e) { + // Ignore all errors. + } + } + } + + return parent::buildConfigurationForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + 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_VALIDATE_INT); + if (FALSE === $matterId) { + $setError($key, t('Missing or invalid matter ID.')); + } + 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(), + ])); + } + } + } + + } + + /** + * {@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->getHandlerSettings($this); + + $build = [ + 'info' => [ + '#prefix' => '
', + '#suffix' => '
', + ], + ]; + + 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]), + ]; + } + break; + } + + 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..68cf7cb --- /dev/null +++ b/src/Settings/ArchiveSettings.php @@ -0,0 +1,37 @@ + ArchiveTarget::class, + ]; + + protected static array $settingsProperties = [ + self::ARCHIVE_TARGET_MATTER => ArchiveTargetMatter::class, + ]; + + const string HANDLER_ID = 'handler_id'; + public string $handlerId; + + 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; + + 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 new file mode 100644 index 0000000..374013e --- /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; + +}