From bd7f350b79c94bf00102551ed54245a19610d2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Thu, 15 Oct 2026 11:08:24 +0200 Subject: [PATCH 01/17] #160 - Full control over the launch form fields (type, options, constraints, default) through the new form UI option Add the `form` UI option declaring the `input` field and each context field of the launch form (type alias or FQCN, form type options with their constraints, default value), whether free context pairs are allowed, and the constraints of the whole form. Typed values are converted into process values by the new ContextValueNormalizer. Deprecate the `entrypoint_type`, `default` and `constraints` UI options (folded into `form` until 4.0). Reserve the `execution_user` context key, validate the format of the free context keys (launch form and schedules), resolve only the requested process option in ProcessConfigurationsManager and the UI options once per launch request. Require symfony/doctrine-bridge. Add documentation, an upgrade guide and tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + UPGRADE.md | 90 +++ composer.json | 1 + docs/cookbooks/form_file_upload.md | 58 +- docs/reference/02-process_ui_options.md | 261 +++++- .../04-process_executions_and_logs.md | 5 +- docs/reference/05-scheduler.md | 2 +- docs/reference/09-notifications.md | 4 +- src/Controller/Admin/Process/LaunchAction.php | 32 +- .../Admin/ProcessScheduleCrudController.php | 2 + src/Form/ContextValueNormalizer.php | 65 ++ src/Form/Type/LaunchType.php | 111 ++- src/Form/Type/ProcessContextType.php | 46 +- src/Manager/ProcessConfigurationsManager.php | 530 +++++++++--- src/Message/ProcessExecuteMessage.php | 5 + tests/App/Form/EntityIdType.php | 43 + tests/App/config/packages.yaml | 124 +++ .../Admin/Process/LaunchActionTest.php | 74 ++ tests/Form/ContextValueNormalizerTest.php | 104 +++ tests/Form/Fixtures/Entity.php | 24 + tests/Form/Fixtures/IntBackedEnum.php | 19 + tests/Form/Fixtures/PureEnum.php | 19 + tests/Form/Fixtures/StringBackedEnum.php | 19 + tests/Form/Fixtures/StringableEntity.php | 29 + tests/Form/Type/LaunchTypeTest.php | 259 +++++- tests/Form/Type/ProcessContextTypeTest.php | 106 ++- tests/Functional/LaunchActionTest.php | 416 +++++++++- tests/Functional/ProcessScheduleCrudTest.php | 144 ++++ .../ProcessConfigurationsManagerTest.php | 756 +++++++++++++++++- .../Runtime/ProcessExtensionRuntimeTest.php | 66 +- 30 files changed, 3165 insertions(+), 250 deletions(-) create mode 100644 src/Form/ContextValueNormalizer.php create mode 100644 tests/App/Form/EntityIdType.php create mode 100644 tests/Controller/Admin/Process/LaunchActionTest.php create mode 100644 tests/Form/ContextValueNormalizerTest.php create mode 100644 tests/Form/Fixtures/Entity.php create mode 100644 tests/Form/Fixtures/IntBackedEnum.php create mode 100644 tests/Form/Fixtures/PureEnum.php create mode 100644 tests/Form/Fixtures/StringBackedEnum.php create mode 100644 tests/Form/Fixtures/StringableEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8f4d4..ce764d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The key of a free context pair (launch form and schedules) must be a valid form field name, not only digits (new `key_constraints` option of `ProcessContextType`). The `execution_user` context value is always the email of the connected user (reserved in the free pairs of the launch form and of the schedules, and in `form.context`; constant `ProcessExecuteMessage::EXECUTION_USER_KEY`, constraint `ProcessContextType::createExecutionUserConstraint()`). Existing schedules with a key that is no longer valid keep running, but must be renamed to be saved from the UI. `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index 20aa9d5..e7666b9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -22,6 +22,96 @@ The routes of the bundle are prefixed with `cleverage_ui_process_`. The former n The routes of the CRUD pages are generated by EasyAdmin from the dashboard route name, e.g. `process_process_execution_index` is now `cleverage_ui_process_process_execution_index`. +### Deprecated UI options of the launch form + +The `entrypoint_type`, `default` and `constraints` UI options are deprecated in favor of the new `form` option (see +[process UI options](docs/reference/02-process_ui_options.md#form-fields)), and will be removed in 4.0: + +| Deprecated option | Replacement | +|---------------------|-----------------------------------------------------------------------------------| +| `entrypoint_type` | `form.input.type` | +| `default.input` | `form.input.default` | +| `default.context` | `form.context..default` (or a free pair with `form.allow_extra_context`) | +| `constraints` | `form.constraints` | + +```yaml +# Before (3.2) +options: + ui: + ui_launch_mode: form + entrypoint_type: file + default: + context: + - key: delimiter + value: ',' + constraints: + - Collection: + fields: + input: + - File: { extensions: [csv] } + context: + - Collection: + fields: + delimiter: + - Choice: { choices: [',', ';'] } + +# After (3.3) +options: + ui: + ui_launch_mode: form + form: + input: + type: file + options: + constraints: + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + delimiter: + type: choice + default: ',' + options: + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } +``` + +- Mixing a deprecated option and its replacement (e.g. `entrypoint_type` and `form.input`, or `constraints` and + `form.constraints`) raises an `InvalidOptionsException`. +- The HTML names of the free context pairs of the launch form change from `launch[context][][key|value]` to + `launch[context][extra][][key|value]`: update the functional tests of your application that submit this + form. +- The `context` child of `LaunchType` is now a compound `FormType` (the declared fields, then the `extra` + `CollectionType` of the free pairs) instead of a `CollectionType`: update the form theme blocks of your application + (`_launch_context_entry_*` becomes `_launch_context_extra_entry_*`), the form type extensions and the code using + `$form->get('context')`. +- The duplicated keys of `default.context` are merged, the last pair wins (two pairs were displayed). +- A process using a deprecated option triggers its deprecations each time its UI options are resolved (process list, + launch form, executions list): expect them in the deprecation logs until the process is migrated. + +### Context keys of the launch form and the schedules + +- The key of a free context pair (launch form and schedules) must contain letters, digits, `_`, `-` and `:`, start with + a letter, a digit or `_`, and not be only digits, like a declared context key. +- The `execution_user` context value is always the email of the connected user when a process is launched from the + UI: a free pair `execution_user` is refused in the launch form and in the schedules, and `execution_user` cannot be + declared in `form.context`. +- Existing schedules whose context contains a key that is no longer valid (or `execution_user`) keep running unchanged: + the cron does not validate their context. But they cannot be saved from the UI until the key is renamed (or the + pair removed), even to change another field: rename it, and update the process accordingly (e.g. its `{{ key }}` + contextualized options), as renaming the key changes the context received by the process. Enabling or disabling a + schedule from the list is not affected (no form). +- The same applies to the deprecated `default.context` option: a pair whose key is not valid pre-fills a row that + prevents the launch form from being submitted, until the user changes it; the process configuration is not + reported as invalid. Rename the key (or move it to `form.context`). + +### Invalid notification and logs options + +`ProcessConfigurationsManager` now resolves only the requested option: invalid `notification` or `logs` options of a +process are no longer reported by the process list. They are only reported when they are used: at the end of an +execution, by the notifier in the Messenger worker (no notification is sent), and by the +`cleverage:ui-process:purge-logs` command. + ## v3.0 ### Import routes diff --git a/composer.json b/composer.json index 8ea6225..68a6115 100644 --- a/composer.json +++ b/composer.json @@ -54,6 +54,7 @@ "symfony/console": "^6.4 || ^7.4 || ^8", "symfony/dependency-injection": "^6.4 || ^7.4 || ^8", "symfony/deprecation-contracts": "^2.5 || ^3", + "symfony/doctrine-bridge": "^6.4 || ^7.4 || ^8", "symfony/doctrine-messenger": "^6.4 || ^7.4 || ^8", "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8", "symfony/event-dispatcher-contracts": "^3", diff --git a/docs/cookbooks/form_file_upload.md b/docs/cookbooks/form_file_upload.md index 2d7da4f..e4c2004 100644 --- a/docs/cookbooks/form_file_upload.md +++ b/docs/cookbooks/form_file_upload.md @@ -17,25 +17,24 @@ clever_age_process: source: CSV upload target: Logs ui_launch_mode: form - entrypoint_type: file - default: + form: + input: + type: file + options: + label: CSV file + constraints: + - NotNull: ~ + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] context: - - key: delimiter - value: ',' - constraints: - - Collection: - fields: - input: - - NotNull: ~ - - File: - extensions: [csv] - context: - - Collection: - fields: - delimiter: - - Choice: - choices: [',', ';'] - message: 'delimiter context must be , or ;. {{ value }} given.' + delimiter: + type: choice + default: ',' + options: + label: Delimiter + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } tasks: log_file: service: '@CleverAge\ProcessBundle\Task\Reporting\LoggerTask' @@ -62,17 +61,16 @@ clever_age_process: How it works: - `ui_launch_mode: form` makes the "Launch" action of the process list open a form instead of a confirmation modal (see [launch modes](../reference/02-process_ui_options.md#launch-modes)). -- `entrypoint_type: file` turns the `input` field into a file upload. As the process has an `entry_point`, the field - is required. The uploaded file is saved in the `upload_directory` directory and its path is sent to the - `log_file` task (see [launch form](../reference/02-process_ui_options.md#launch-form)). -- `default.context` pre-fills a `delimiter` line in the context collection - (see [default values](../reference/02-process_ui_options.md#default-values)). It is injected into the - `InputCsvReaderTask` through the `{{ delimiter }}` contextualized option, exactly like `-c delimiter:";"` on the - console. -- `constraints` validate the whole form data (`input` and `context`): the process is not queued while the file is not - a CSV or the delimiter is not `,` or `;` (see [constraints](../reference/02-process_ui_options.md#constraints)). - As the `Collection` constraint rejects extra fields, users cannot add other context values: add - `allowExtraFields: true` to allow them. +- `form.input` with `type: file` turns the `input` field into a file upload. As the process has an `entry_point`, the + field is required (HTML attribute); its `NotNull` and `File` constraints are checked by the server: the process is + not queued while no file is uploaded or the file is not a CSV, and the error is displayed under the field. The + uploaded file is saved in the `upload_directory` directory and its path is sent to the `log_file` task (see + [launch form](../reference/02-process_ui_options.md#launch-form)). +- `form.context.delimiter` adds a `delimiter` select, pre-selected on `,` + (see [form fields](../reference/02-process_ui_options.md#form-fields)). A value outside of the choices is refused by + the field itself. The selected value is injected into the `InputCsvReaderTask` through the `{{ delimiter }}` + contextualized option, exactly like `-c delimiter:";"` on the console. +- Users can still add free key/value pairs to the context: set `form.allow_extra_context: false` to forbid them. - On submit, a message is sent to the `execute_process` transport, so a `messenger:consume execute_process` worker must be running (see [messenger](../reference/08-messenger.md)). The connected user email is added to the context as `execution_user`. @@ -92,4 +90,4 @@ curl --location 'https://your-domain.com/http/process/execute' \ --form 'context[delimiter]=";"' ``` -Note that the launch form `constraints` are not applied to HTTP API calls. +Note that the launch form fields and constraints are not applied to HTTP API calls. diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 5421500..626b59d 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -19,6 +19,16 @@ clever_age_process: source: target: ui_launch_mode: + form: + input: # + type: # default: text + options: # form type options, default: [] + default: # initial value, default: null + context: + : # same keys as "input" + allow_extra_context: # default: true + constraints: + # Deprecated since 3.3, use "form" instead entrypoint_type: default: input: @@ -45,9 +55,10 @@ Options | `source` | `string`, `null` | `null` | Free label describing where the data comes from. Displayed in the process list and in the executions list. | | `target` | `string`, `null` | `null` | Free label describing where the data goes. Displayed in the process list and in the executions list. | | `ui_launch_mode` | `string`, `null` | `modal` | Behaviour of the "Launch" (rocket) action of the process list. See [launch modes](#launch-modes). | -| `entrypoint_type` | `string` | `text` | `text` or `file`: type of the `input` field of the launch form (`form` launch mode only). | -| `default` | `array` | `{input: null, context: []}` | Initial values of the launch form (`form` launch mode only). See [default values](#default-values). | -| `constraints` | `array` | `[]` | Symfony validation constraints applied to the launch form (`form` launch mode only). See [constraints](#constraints). | +| `form` | `array` | `[]` | Fields, default values and constraints of the launch form (`form` launch mode only). See [form fields](#form-fields). | +| `entrypoint_type` | `string` | `text` | *Deprecated since 3.3, use `form.input.type`.* `text` or `file`: type of the `input` field of the launch form. | +| `default` | `array` | `{input: null, context: []}` | *Deprecated since 3.3, use `form.input.default` and `form.context..default`.* See [default values](#default-values). | +| `constraints` | `array` | `[]` | *Deprecated since 3.3, use `form.constraints`.* Constraints applied to the launch form. See [constraints](#constraints). | | `run` | `mixed` | `null` | Accepted for backward compatibility, not used. | Process visibility @@ -69,28 +80,184 @@ Launch modes In every mode, the process is not executed by the web request: a message is dispatched to the `execute_process` Messenger transport and a worker runs it (see [messenger](08-messenger.md)). The UI adds the email of the connected -user to the process context, under the `execution_user` key. +user to the process context, under the `execution_user` key: this value always wins, the launch form cannot set it. ### Launch form The launch form (`/process/launch?process=`) contains: -- an `input` field: a text field when `entrypoint_type` is `text`, a file upload field when it is `file`. It is - required if the process defines an `entry_point`. -- a `context` collection of key/value pairs (both are required when a line is added). Each pair becomes a process - context value, like `-c key:value` on the `cleverage:process:execute` console command. +- an `input` field, of the type declared by `form.input` (a text field by default). It is required (HTML attribute) + if the process defines an `entry_point`, unless `form.input.options.required` says otherwise. +- a `context` group: one field per key declared in `form.context`, followed (when `form.allow_extra_context` is + `true`, the default) by a collection of free key/value pairs (both are required when a line is added). Each field + or pair becomes a process context value, like `-c key:value` on the `cleverage:process:execute` console command. -When `entrypoint_type` is `file`, the uploaded file is saved to the `upload_directory` directory (see +The input can be of any type, but only a single uploaded file is saved: when the input is a `file` field, the +uploaded file is saved to the `upload_directory` directory (see [bundle configuration](01-bundle_configuration.md#container-parameters)) with a random name keeping the original extension, and its absolute path is passed as process input. The file is not removed after the execution. +Form fields +----------- + +`form` declares the fields of the launch form: + +```yaml +options: + ui: + ui_launch_mode: form + form: + input: + type: file + options: + label: CSV file + constraints: + - NotNull: ~ + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + delimiter: + type: choice + default: ',' + options: + label: Delimiter + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + dry_run: + type: checkbox + default: false + options: { required: false } + comment: ~ # a text field without default value + allow_extra_context: true + constraints: [] +``` + +| Key | Type | Default | Description | +|----------------------------|------------------------|---------|------------------------------------------------------------------------------------------------------------------| +| `form.input` | field spec, `null` | `null` | The `input` field. `null`: a text field (or the deprecated `entrypoint_type`). | +| `form.context` | map of field specs | `[]` | The declared context fields, by context key, in their display order. `null` is a text field without default. | +| `form.allow_extra_context` | `bool` | `true` | Whether free key/value pairs can be added to the context, for the keys that are not declared. | +| `form.constraints` | `array` | `[]` | Constraints applied to the whole form data, see [constraints](#constraints). | + +### Field spec + +A field spec (`form.input`, `form.context.`) accepts exactly three keys: + +| Key | Type | Default | Description | +|-----------|----------|---------|----------------------------------------------------------------------------------------------------------------| +| `type` | `string` | `text` | A form type alias (see below) or the FQCN of a Symfony form type. | +| `options` | `array` | `[]` | The options of the form type (`label`, `help`, `attr`, `choices`, `constraints`...). | +| `default` | `mixed` | `null` | The initial value of the field. | + +The context keys must be valid form field names (letters, digits, `_`, `-` and `:`, starting with a letter, a digit +or `_`). A purely numeric key (e.g. `123`, even quoted in YAML) is not supported: PHP casts it to an integer. The +`extra` key (the free context pairs) and the `execution_user` key (the email of the connected user) are reserved. + +The type aliases are the block prefixes of the Symfony core form types, and `entity`: + +| Alias | Form type | +|-----------------------------------------------------------------------------|---------------------------------------------------------------------------------| +| `text`, `textarea`, `email`, `password`, `search`, `url`, `tel`, `hidden` | `TextType`, `TextareaType`, `EmailType`, `PasswordType`, `SearchType`, `UrlType`, `TelType`, `HiddenType` | +| `integer`, `number`, `money`, `percent`, `range` | `IntegerType`, `NumberType`, `MoneyType`, `PercentType`, `RangeType` | +| `checkbox`, `radio`, `choice`, `enum` | `CheckboxType`, `RadioType`, `ChoiceType`, `EnumType` | +| `country`, `currency`, `language`, `locale`, `timezone` | `CountryType`, `CurrencyType`, `LanguageType`, `LocaleType`, `TimezoneType` | +| `date`, `datetime`, `time`, `birthday`, `week`, `dateinterval` | `DateType`, `DateTimeType`, `TimeType`, `BirthdayType`, `WeekType`, `DateIntervalType` | +| `color`, `file`, `uuid`, `ulid`, `collection`, `repeated` | `ColorType`, `FileType`, `UuidType`, `UlidType`, `CollectionType`, `RepeatedType` | +| `entity` | `Symfony\Bridge\Doctrine\Form\Type\EntityType` | + +The aliases are case-sensitive. Any other type is a FQCN: it must implement `Symfony\Component\Form\FormTypeInterface` +and must not be a button type. Only the existence of the class is checked when the options are resolved: a form type +with dependencies must be a service (tag `form.type`, autoconfigured by default), and an option that the type does +not define (e.g. `choices` on a `text` field) fails when the form is built (500 on the launch page). + +### Constraints of a field + +`options.constraints` uses the same syntax as the [`constraints`](#constraints) option: a list of constraint short +names (or FQCN) with their options. A violation is displayed under the field. + +The `extensions` option of the `File` constraint also checks the detected MIME type of the file. A CSV file is +often detected as `text/plain` (semicolon delimiter, single column, few lines): list the accepted MIME types per +extension (`extensions: { csv: ['text/csv', 'text/plain'] }`). + +`options.required` is only the HTML `required` attribute (Symfony default: `true`, including for a `checkbox`, which +must then be checked): the server-side validation relies on `options.constraints` (e.g. `NotBlank`). + +The `data`, `mapped`, `property_path` and `inherit_data` options are not supported (they break the mapping of the +context and of the default values): use `default` for the initial value. + +### Default values of the fields + +`default` is the initial value of the field, in the model format of its type: +- a `file` field cannot have a default value; +- for `date`, `datetime` and `time`, the YAML value is a string: set `options.input: string` (with the matching + `options.input_format`), or keep a `null` default, otherwise the string cannot be transformed; +- for `choice`, the default value is one of the choices values; +- for `dateinterval`, the YAML value is a string: set `options.input: string` (an ISO 8601 duration such as `P1D`), or + keep a `null` default. + +### File fields + +A `file` field is only supported for the input, and for a single file (`options.multiple: true` is refused). A form +type whose parent is `FileType` (through `getParent()`) is not detected as a file field. + +### Free context pairs + +With `allow_extra_context: true`, the free key/value pairs are displayed after the declared fields, under a "Context +(key/value)" label (no label when no context key is declared). With `allow_extra_context: false`, no free pair can be +added. + +The key of a free pair follows the rule of the declared context keys: letters, digits, `_`, `-` and `:`, starting with +a letter, a digit or `_`, and not only digits. Otherwise, it is reported as a validation error on its key (`The +context key "a b" is not valid: ...`), and the process is not launched. The same applies to a free pair whose key is: +- a declared field (`The context key "delimiter" is a field of the launch form, use this field instead.`); +- `execution_user` (`The context key "execution_user" is reserved: it is set to the email of the connected user.`). + +The key format rule also applies to the context pairs of the [schedules](05-scheduler.md). + +> **Free pairs and placeholders.** A free pair gives a value to any context key, so to any `{{ key }}` placeholder of +> the options of the process tasks, not only to the keys declared in `form.context`. As soon as a process declares its +> context fields in `form.context`, set `allow_extra_context: false` so that only these keys can be set from the UI +> (the default stays `true`, for backward compatibility). + +The HTML names of the fields are `launch[input]`, `launch[context][]` for the declared fields and +`launch[context][extra][][key|value]` for the free pairs. + +### Context values + +The values of the form are converted before being given to the process (after the validation, so that the +constraints apply to the typed values): + +| Form data | Process value | +|-----------------------------------------------|-------------------------------------------------------------------| +| `null`, `bool`, `int`, `float`, `string` | unchanged | +| `array` | converted recursively, keys kept | +| backed enum | its `value` | +| pure enum | its `name` | +| `DateTimeInterface` | formatted as `DATE_ATOM` (`2026-01-02T03:04:05+01:00`) | +| `DateInterval` | an ISO 8601 duration with every part (`P0Y0M1DT0H0M0S`), `-` prefixed when inverted | +| file | refused (`LogicException`): file fields are only supported for the input | +| `Stringable` object | its string | +| other object | refused (`LogicException`, 500) | + +A declared field left empty gives the `null` value (`false` for a `checkbox`). + +> **`entity` fields.** The data of an `EntityType` is the entity itself: the `choice_value` option only changes the +> HTML value of the options, not the data given to the process. The entity must implement `\Stringable`: its string +> becomes the context value. Otherwise the launch fails with a `LogicException` ("cannot be used as a process context +> value"). Prefer a `choice` field with scalar choices (e.g. the identifiers of the entities). + Default values -------------- +*Deprecated since 3.3, use `form.input.default` and `form.context..default` (see +[migrating from the deprecated options](#migrating-from-the-deprecated-options)).* + `default.input` pre-fills the input field. It is ignored when `entrypoint_type` is `file` (a file field cannot be pre-filled). `default.context` is a list of `key`/`value` pairs (both required) pre-filling the context collection. The user can -change, remove or add pairs before launching. +change, remove or add pairs before launching. A pair whose key is declared in `form.context` pre-fills this field (if +the field has no `default`); when a key appears several times, the last pair wins. ```yaml options: @@ -109,11 +276,15 @@ options: Constraints ----------- -`constraints` uses the same syntax as the [ValidatorTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/validator_task.md) +*The `constraints` option is deprecated since 3.3, use `form.constraints`, which has the same syntax.* + +`form.constraints` (or the deprecated `constraints`) uses the same syntax as the [ValidatorTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/validator_task.md) of the process bundle (and as Symfony YAML validation mapping): a list of constraint short names (or FQCN) with their options. Constraints are applied to the whole form data, which is an array with two keys: -- `input`: the input string, or the `UploadedFile` object when `entrypoint_type` is `file`, -- `context`: the context as an associative array (`key => value`). +- `input`: the data of the input field (e.g. a string, or the `UploadedFile` object of a `file` field), +- `context`: the context as an associative array (`key => value`), the declared keys first, with their typed values. + +A violation on a declared context key (`[context][]`) is displayed under its field. While a constraint is violated, the process is not launched and the form is displayed again with the violation messages. @@ -122,27 +293,57 @@ messages. options: ui: ui_launch_mode: form - entrypoint_type: file - default: + form: + input: + type: file context: - - key: delimiter - value: ',' - constraints: - - Collection: - fields: - input: - - File: - extensions: [csv] - context: - - Collection: - fields: - delimiter: - - Choice: - choices: [',', ';'] - message: 'delimiter context must be , or ;. {{ value }} given.' + delimiter: + default: ',' + constraints: + - Collection: + fields: + input: + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + - Collection: + fields: + delimiter: + - Choice: + choices: [',', ';'] + message: 'delimiter context must be , or ;. {{ value }} given.' ``` +The constraints of a single field are simpler to declare in its `options.constraints` (see +[form fields](#constraints-of-a-field)). + Note that the `Collection` constraint rejects missing and extra fields by default: use `allowExtraFields: true` (or `allowMissingFields: true`) if users may add other context values. See the [form with file upload](../cookbooks/form_file_upload.md) cookbook for a complete example. + +Migrating from the deprecated options +------------------------------------- + +Since 3.3, `entrypoint_type`, `default` and `constraints` are deprecated (a deprecation is triggered each time the +UI options of the process are resolved: process list, launch form, executions list; when the option is set, even to +an empty value), and will be removed in 4.0: + +| Deprecated option | Replacement | +|---------------------|-----------------------------------------------------------------------------------------------| +| `entrypoint_type` | `form.input.type` | +| `default.input` | `form.input.default` | +| `default.context` | `form.context..default` (or a free pair with `form.allow_extra_context`) | +| `constraints` | `form.constraints` | + +Until then, the deprecated options are folded into `form`. Mixing a deprecated option and its replacement raises an +`InvalidOptionsException`: +- `entrypoint_type` with `form.input`; +- `constraints` with `form.constraints` (even empty); +- `default.input` with `form.input.default`; +- a `default.context` pair with the `default` of the same declared key; +- a `default.context` pair whose key is not declared, with `form.allow_extra_context: false`. + +See the [upgrade guide](../../UPGRADE.md) for an example. diff --git a/docs/reference/04-process_executions_and_logs.md b/docs/reference/04-process_executions_and_logs.md index 6ff4eec..d1e03e7 100644 --- a/docs/reference/04-process_executions_and_logs.md +++ b/docs/reference/04-process_executions_and_logs.md @@ -31,7 +31,7 @@ Executions are stored in the `process_execution` table (`CleverAge\UiProcessBund | `heartbeatAt` | Last sign of life of the running process, see [not responding executions](#not-responding-executions). | | `logFilename` | Name of the log file. | | `report` | Associative array, see [report](#report). | -| `context` | Process context (including `execution_user` when launched from the UI). | +| `context` | Process context (including `execution_user` when launched from the UI, reserved in the launch form and the schedules). | ### Executions list @@ -193,6 +193,9 @@ clever_age_process: # ... ``` +Invalid `logs` options raise an options resolver error when the purge command runs (since 3.3, they are no longer +reported by the process list): run the command with `--dry-run` after changing them. + Schedule the command, e.g. every night with a cron: ``` diff --git a/docs/reference/05-scheduler.md b/docs/reference/05-scheduler.md index a2481e7..89d26a7 100644 --- a/docs/reference/05-scheduler.md +++ b/docs/reference/05-scheduler.md @@ -15,7 +15,7 @@ Schedules are stored in the `process_schedule` table (`CleverAge\UiProcessBundle | `type` | `cron` or `every`. | | `expression` | `cron`: a cron expression, e.g. `*/5 * * * *` or `@daily` (see [cron expression triggers](https://symfony.com/doc/current/scheduler.html#cron-expression-triggers)). `every`: a relative time, e.g. `5 seconds`, `1 hour`, `1 day` (see [periodical triggers](https://symfony.com/doc/current/scheduler.html#periodical-triggers)); it must be parsable by `strtotime()`. | | `input` | Optional process input (string, 255 characters max), e.g. a file path. | -| `context` | Optional list of key/value pairs, passed as process context. | +| `context` | Optional list of key/value pairs, passed as process context. A key contains letters, digits, `_`, `-` and `:`, starts with a letter, a digit or `_`, and is not only digits. The `execution_user` key is reserved (set to the email of the connected user when a process is launched from the UI). | | `enabled` | A disabled schedule is not run (default: enabled). It can be switched directly from the schedules list. | The schedules list displays the next execution date of the enabled `cron` schedules. diff --git a/docs/reference/09-notifications.md b/docs/reference/09-notifications.md index 02f42f2..2d4bf67 100644 --- a/docs/reference/09-notifications.md +++ b/docs/reference/09-notifications.md @@ -77,7 +77,9 @@ clever_age_process: | `recipients` | `array[]` | Recipients, with an `email` and/or a `phone` (required by the `email` and `sms` channels). Empty: the `admin_recipients` of the notifier. | A key missing (or `null`) in the process options is inherited from the bundle configuration. Invalid process options -raise an options resolver error. +raise an options resolver error when the notification is sent, at the end of an execution, in the Messenger worker: +no notification is then sent. Since 3.3, they are no longer reported by the process list, check them when you change +them. Statuses -------- diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index e4a5f39..f40c260 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -15,6 +15,7 @@ use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\UiProcessBundle\Entity\User; +use CleverAge\UiProcessBundle\Form\ContextValueNormalizer; use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; @@ -23,7 +24,6 @@ use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -77,31 +77,35 @@ public function __invoke( LaunchType::class, null, [ - 'constraints' => $uiOptions['constraints'], + 'constraints' => $uiOptions['form']['constraints'], 'process_code' => $processCode, + // Not resolved again by the form type + 'ui_options' => $uiOptions, ] ); if (false === $form->isSubmitted()) { - $default = $uiOptions['default']; - if (false === $form->get('input')->getConfig()->getType()->getInnerType() instanceof TextType - && isset($default['input']) - ) { - unset($default['input']); - } - $form->setData($default); + $form->setData($this->processConfigurationsManager->buildLaunchFormDefaults($uiOptions)); } $form->handleRequest($this->requestStack->getMainRequest()); if ($form->isSubmitted() && $form->isValid()) { + // Normalized after the validation: the constraints apply to the typed values + $normalizer = new ContextValueNormalizer(); $input = $form->get('input')->getData(); if ($input instanceof UploadedFile) { $filename = \sprintf('%s/%s.%s', $uploadDirectory, Uuid::v4(), $input->getClientOriginalExtension()); (new Filesystem())->dumpFile($filename, $input->getContent()); $input = $filename; + } else { + $input = $normalizer->normalize($input, 'input'); } /** @var string $processCode */ $processCode = $form->getConfig()->getOption('process_code'); - /** @var array $context */ - $context = $form->get('context')->getData(); + /** @var array $data */ + $data = $form->get('context')->getData() ?? []; + $context = []; + foreach ($data as $key => $value) { + $context[(string) $key] = $normalizer->normalize($value, (string) $key); + } $this->dispatch($processCode, $input, $context); $this->addFlash( 'success', @@ -128,10 +132,8 @@ protected function dispatch(string $processCode, mixed $input = null, array $con $message = new ProcessExecuteMessage( $processCode, $input, - array_merge( - ['execution_user' => $this->getUser()?->getEmail()], - $context - ) + // The connected user always wins over a context value + [ProcessExecuteMessage::EXECUTION_USER_KEY => $this->getUser()?->getEmail()] + $context ); $this->messageBus->dispatch($message); } diff --git a/src/Controller/Admin/ProcessScheduleCrudController.php b/src/Controller/Admin/ProcessScheduleCrudController.php index 46212de..2e53a62 100644 --- a/src/Controller/Admin/ProcessScheduleCrudController.php +++ b/src/Controller/Admin/ProcessScheduleCrudController.php @@ -108,6 +108,8 @@ public function configureFields(string $pageName): iterable ->setFormTypeOption('entry_type', ProcessContextType::class) ->hideOnIndex() ->setFormTypeOption('entry_options.label', 'Context (key/value)') + // As in the launch form: set to the email of the connected user when a process is launched from the UI + ->setFormTypeOption('entry_options.key_constraints', [ProcessContextType::createExecutionUserConstraint()]) ->setFormTypeOption('label', '') ->setFormTypeOption('required', false), ]; diff --git a/src/Form/ContextValueNormalizer.php b/src/Form/ContextValueNormalizer.php new file mode 100644 index 0000000..ed2bf7a --- /dev/null +++ b/src/Form/ContextValueNormalizer.php @@ -0,0 +1,65 @@ +|null + * + * @throws \LogicException when the value cannot be converted + */ + public function normalize(mixed $value, string $name): mixed + { + if (null === $value || \is_scalar($value)) { + return $value; + } + if (\is_array($value)) { + $normalized = []; + foreach ($value as $key => $item) { + $normalized[$key] = $this->normalize($item, \sprintf('%s[%s]', $name, $key)); + } + + return $normalized; + } + // A backed enum is also a unit enum + if ($value instanceof \BackedEnum) { + return $value->value; + } + if ($value instanceof \UnitEnum) { + return $value->name; + } + if ($value instanceof \DateTimeInterface) { + return $value->format(\DATE_ATOM); + } + // An ISO 8601 duration, "-" prefixed when inverted + if ($value instanceof \DateInterval) { + return $value->format('%rP%yY%mM%dDT%hH%iM%sS'); + } + // A file is also stringable + if ($value instanceof \SplFileInfo) { + throw new \LogicException(\sprintf('The value of "%s" is a file ("%s"): file fields are only supported for the input.', $name, $value::class)); + } + if ($value instanceof \Stringable) { + return (string) $value; + } + + throw new \LogicException(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', $name, get_debug_type($value))); + } +} diff --git a/src/Form/Type/LaunchType.php b/src/Form/Type/LaunchType.php index 35551e1..1604bcc 100644 --- a/src/Form/Type/LaunchType.php +++ b/src/Form/Type/LaunchType.php @@ -14,18 +14,24 @@ namespace CleverAge\UiProcessBundle\Form\Type; use CleverAge\ProcessBundle\Configuration\TaskConfiguration; +use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; -/** @template-extends AbstractType> */ +/** + * @phpstan-import-type UiOptions from ProcessConfigurationsManager + * + * @template-extends AbstractType> + */ class LaunchType extends AbstractType { public function __construct( @@ -39,41 +45,86 @@ public function buildForm(FormBuilderInterface $builder, array $options): void /** @var string $code */ $code = $options['process_code']; $configuration = $this->registry->getProcessConfiguration($code); - $uiOptions = $this->configurationsManager->getUiOptions($code); - if (isset($uiOptions['entrypoint_type'])) { - $builder->add( - 'input', - 'file' === $uiOptions['entrypoint_type'] ? FileType::class : TextType::class, + /** @var UiOptions $uiOptions */ + $uiOptions = $options['ui_options'] ?? $this->configurationsManager->getUiOptions($code) ?? throw MissingProcessException::create($code); + $form = $uiOptions['form']; + + // A "required" form option of the input takes precedence + $builder->add( + 'input', + $form['input']['type'], + $form['input']['options'] + ['required' => $configuration->getEntryPoint() instanceof TaskConfiguration] + ); + + // Not "required: false": a child of a form that is not required is never required (HTML attribute), the + // declared fields follow their own "required" option; the free context pairs are not required + $context = $builder->create('context', FormType::class); + foreach ($form['context'] as $key => $field) { + $context->add($key, $field['type'], $field['options']); + } + if ($form['allow_extra_context']) { + $context->add( + 'extra', + CollectionType::class, [ - 'required' => $configuration->getEntryPoint() instanceof TaskConfiguration, + 'entry_type' => ProcessContextType::class, + 'entry_options' => [ + 'reserved_keys' => array_keys($form['context']), + 'key_constraints' => [ProcessContextType::createExecutionUserConstraint()], + ], + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'mapped' => false, + 'label' => [] === $form['context'] ? false : 'Context (key/value)', ] ); + // On POST_SET_DATA: the data mapper resets the unmapped children with their configured data after PRE_SET_DATA + $context->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($form): void { + /** @var array $data */ + $data = $event->getData() ?? []; + $rows = []; + foreach (array_diff_key($data, $form['context']) as $key => $value) { + $rows[] = ['key' => $key, 'value' => $value]; + } + $event->getForm()->get('extra')->setData($rows); + }); } - $builder->add( - 'context', - CollectionType::class, - [ - 'entry_type' => ProcessContextType::class, - 'allow_add' => true, - 'allow_delete' => true, - 'required' => false, - ] - ); - $builder->get('context')->addModelTransformer(new CallbackTransformer( - static fn ($data) => $data ?? [], - // A row without key is left out (it is reported by the NotBlank constraint of ProcessContextType): a null - // key is deprecated as array offset, and an empty key breaks the property paths of the process constraints - static fn (?array $data) => array_column( - array_filter($data ?? [], static fn ($row): bool => \is_array($row) && null !== ($row['key'] ?? null) && '' !== $row['key']), - 'value', - 'key' - ), - )); + $context->addEventListener(FormEvents::SUBMIT, static function (FormEvent $event) use ($form): void { + /** @var array $data */ + $data = $event->getData() ?? []; + $extra = []; + if ($event->getForm()->has('extra')) { + /** @var array $rows */ + $rows = $event->getForm()->get('extra')->getData() ?? []; + // A row without key is left out (it is reported by the NotBlank constraint of ProcessContextType): a + // null key is deprecated as array offset, and an empty key breaks the property paths of the process + // constraints + $extra = array_column( + array_filter($rows, static fn ($row): bool => \is_array($row) && null !== ($row['key'] ?? null) && '' !== $row['key']), + 'value', + 'key' + ); + } + // The declared keys take precedence, a free row with a declared key is reported by ProcessContextType + $event->setData(array_intersect_key($data, $form['context']) + $extra); + }); + $builder->add($context); + } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + // The context group itself is not required: its label is rendered as before 3.3, without the required mark + $view['context']->vars['required'] = false; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setRequired('process_code'); + // The UI options of the process, as resolved by ProcessConfigurationsManager::getUiOptions(): resolved from + // the process code when null + $resolver->setDefault('ui_options', null); + $resolver->setAllowedTypes('ui_options', ['null', 'array']); } #[\Override] diff --git a/src/Form/Type/ProcessContextType.php b/src/Form/Type/ProcessContextType.php index d95fd19..4eac854 100644 --- a/src/Form/Type/ProcessContextType.php +++ b/src/Form/Type/ProcessContextType.php @@ -13,23 +13,49 @@ namespace CleverAge\UiProcessBundle\Form\Type; +use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\Regex; /** @template-extends AbstractType */ class ProcessContextType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + $keyConstraints = [ + new NotBlank(), + // The rule of FormConfigBuilder::isValidName() applied to the declared context keys of the launch form, and + // not only digits (PHP casts such an array key to an integer) + new Regex( + pattern: '/^(?![0-9]+$)[a-zA-Z0-9_][a-zA-Z0-9_\-:]*$/D', + message: 'The context key {{ value }} is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.' + ), + ]; + /** @var string[] $reservedKeys */ + $reservedKeys = $options['reserved_keys']; + if ([] !== $reservedKeys) { + // The keys of the fields of the launch form + $keyConstraints[] = new Choice( + choices: $reservedKeys, + message: 'The context key {{ value }} is a field of the launch form, use this field instead.', + match: false + ); + } + /** @var Constraint[] $additionalKeyConstraints */ + $additionalKeyConstraints = $options['key_constraints']; + $keyConstraints = [...$keyConstraints, ...$additionalKeyConstraints]; $builder->add( 'key', null, [ 'label' => 'Context Key', 'attr' => ['placeholder' => 'key'], - 'constraints' => [new NotBlank()], + 'constraints' => $keyConstraints, ] )->add( 'value', @@ -42,7 +68,25 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ); } + /** + * The constraint of a context key reserving "execution_user", set to the email of the connected user when a process + * is launched from the UI. + */ + public static function createExecutionUserConstraint(): Choice + { + return new Choice( + choices: [ProcessExecuteMessage::EXECUTION_USER_KEY], + message: 'The context key {{ value }} is reserved: it is set to the email of the connected user.', + match: false + ); + } + public function configureOptions(OptionsResolver $resolver): void { + $resolver->setDefault('reserved_keys', []); + $resolver->setAllowedTypes('reserved_keys', 'string[]'); + // Additional constraints of the key + $resolver->setDefault('key_constraints', []); + $resolver->setAllowedTypes('key_constraints', Constraint::class.'[]'); } } diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index d2e36d8..2c6c97a 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -14,15 +14,46 @@ namespace CleverAge\UiProcessBundle\Manager; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; +use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; +use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\ButtonTypeInterface; +use Symfony\Component\Form\Extension\Core\Type; +use Symfony\Component\Form\FormConfigBuilder; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\Form\SubmitButtonTypeInterface; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraint; /** + * @phpstan-type FormFieldOptions array{ + * 'type': class-string>, + * 'options': array, + * 'default': mixed + * } + * @phpstan-type FormOptions array{ + * 'input': FormFieldOptions, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[] + * } + * @phpstan-type ResolvedFormOptions array{ + * 'input': FormFieldOptions|null, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[] + * } + * @phpstan-type ResolvedUiOptions array{ + * 'entrypoint_type': string, + * 'constraints': Constraint[], + * 'default': array{'input': mixed, 'context': array}, + * 'form': ResolvedFormOptions + * } * @phpstan-type UiOptions array{ * 'source': ?string, * 'target': ?string, @@ -30,8 +61,10 @@ * 'entrypoint_type': string, * 'constraints': Constraint[], * 'run': ?bool, - * 'default': array{'input': mixed, 'context': array} + * 'default': array{'input': mixed, 'context': array}, + * 'form': FormOptions * } + * @phpstan-type LaunchFormData array{'input': mixed, 'context': array} * @phpstan-type NotificationOptions array{ * 'enabled': ?bool, * 'statuses': ?string[], @@ -45,6 +78,54 @@ */ final readonly class ProcessConfigurationsManager { + /** + * The form type aliases of the launch form fields: the block prefixes of the Symfony core data types, and "entity". + * + * @var array + */ + private const FORM_TYPE_ALIASES = [ + 'text' => Type\TextType::class, + 'textarea' => Type\TextareaType::class, + 'email' => Type\EmailType::class, + 'password' => Type\PasswordType::class, + 'search' => Type\SearchType::class, + 'url' => Type\UrlType::class, + 'tel' => Type\TelType::class, + 'hidden' => Type\HiddenType::class, + 'integer' => Type\IntegerType::class, + 'number' => Type\NumberType::class, + 'money' => Type\MoneyType::class, + 'percent' => Type\PercentType::class, + 'range' => Type\RangeType::class, + 'checkbox' => Type\CheckboxType::class, + 'radio' => Type\RadioType::class, + 'choice' => Type\ChoiceType::class, + 'enum' => Type\EnumType::class, + 'country' => Type\CountryType::class, + 'currency' => Type\CurrencyType::class, + 'language' => Type\LanguageType::class, + 'locale' => Type\LocaleType::class, + 'timezone' => Type\TimezoneType::class, + 'date' => Type\DateType::class, + 'datetime' => Type\DateTimeType::class, + 'time' => Type\TimeType::class, + 'birthday' => Type\BirthdayType::class, + 'week' => Type\WeekType::class, + 'dateinterval' => Type\DateIntervalType::class, + 'color' => Type\ColorType::class, + 'file' => Type\FileType::class, + 'uuid' => Type\UuidType::class, + 'ulid' => Type\UlidType::class, + 'collection' => Type\CollectionType::class, + 'repeated' => Type\RepeatedType::class, + 'entity' => EntityType::class, + ]; + + /** + * The form options of a launch form field that break the mapping of the context or the default values. + */ + private const UNSUPPORTED_FIELD_OPTIONS = ['data', 'mapped', 'property_path', 'inherit_data']; + public function __construct(private ProcessConfigurationRegistry $registry) { } @@ -72,7 +153,42 @@ public function getUiOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions())['ui']; + /** @var UiOptions $options */ + $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'ui')['ui']; + + return $options; + } + + /** + * The initial data of the launch form of the process: the default value of the input, and the default values of + * the context, the declared keys first (in their order of declaration), then the free keys of the deprecated + * "default.context" option. + * + * @return LaunchFormData + */ + public function getLaunchFormDefaults(string $processCode): array + { + return $this->buildLaunchFormDefaults($this->getUiOptions($processCode) ?? throw MissingProcessException::create($processCode)); + } + + /** + * The initial data of the launch form, from the UI options already resolved by getUiOptions(). + * + * @param UiOptions $uiOptions + * + * @return LaunchFormData + */ + public function buildLaunchFormDefaults(array $uiOptions): array + { + $context = array_map(static fn (array $field): mixed => $field['default'], $uiOptions['form']['context']); + foreach ($uiOptions['default']['context'] as $row) { + $key = (string) $row['key']; + if (!\array_key_exists($key, $uiOptions['form']['context'])) { + $context[$key] = $row['value']; + } + } + + return ['input' => $uiOptions['form']['input']['default'], 'context' => $context]; } /** @@ -88,7 +204,10 @@ public function getNotificationOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions())['notification']; + /** @var NotificationOptions $options */ + $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'notification')['notification']; + + return $options; } /** @@ -104,115 +223,344 @@ public function getLogsOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions())['logs']; + /** @var LogsOptions $options */ + $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'logs')['logs']; + + return $options; } /** - * @param array $options + * Resolves the given option only: the other ones are only checked as arrays, so that an invalid option (e.g. the + * launch form) does not break the others (e.g. the notification of the executions or the purge of the logs). + * + * @param array $options + * @param 'ui'|'notification'|'logs' $option * - * @return array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} + * @return array{'ui': array, 'notification': array, 'logs': array} */ - private function resolveOptions(array $options): array + private function resolveOptions(array $options, string $processCode, string $option): array { $resolver = new OptionsResolver(); - $resolver->setDefault('notification', []); - $resolver->setAllowedTypes('notification', 'array'); - $resolver->setNormalizer('notification', static function (Options $options, array $notification): array { - $notificationResolver = new OptionsResolver(); - $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); - $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); - $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); - $notificationResolver->setAllowedValues('statuses', static function (?array $statuses): bool { - /** @var string[]|null $statusValues */ - $statusValues = $statuses; - - return null === $statusValues || [] === array_diff($statusValues, NotificationTrigger::values()); - }); - $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); - $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); - $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { - if (null === $recipients) { - return null; + $resolver->setDefaults(['notification' => [], 'logs' => [], 'ui' => []]); + foreach (['notification', 'logs', 'ui'] as $name) { + $resolver->setAllowedTypes($name, 'array'); + } + $resolver->setNormalizer($option, match ($option) { + 'notification' => static fn (Options $options, array $notification): array => self::resolveNotificationOptions($notification), + 'logs' => static fn (Options $options, array $logs): array => self::resolveLogsOptions($logs), + 'ui' => static fn (Options $options, array $ui): array => self::resolveUiOptions($ui, $processCode), + }); + + /** + * @var array{'ui': array, 'notification': array, 'logs': array} $resolved + */ + $resolved = $resolver->resolve($options); + + return $resolved; + } + + /** + * @param array $notification + * + * @return array + */ + private static function resolveNotificationOptions(array $notification): array + { + $notificationResolver = new OptionsResolver(); + $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); + $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); + $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); + $notificationResolver->setAllowedValues('statuses', static function (?array $statuses): bool { + /** @var string[]|null $statusValues */ + $statusValues = $statuses; + + return null === $statusValues || [] === array_diff($statusValues, NotificationTrigger::values()); + }); + $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); + $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); + $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { + if (null === $recipients) { + return null; + } + $recipientResolver = new OptionsResolver(); + $recipientResolver->setDefaults(['email' => null, 'phone' => null]); + $recipientResolver->setAllowedTypes('email', ['null', 'string']); + $recipientResolver->setAllowedTypes('phone', ['null', 'string']); + + $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { + if (null === $options['email'] && null === $phone) { + throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); } - $recipientResolver = new OptionsResolver(); - $recipientResolver->setDefaults(['email' => null, 'phone' => null]); - $recipientResolver->setAllowedTypes('email', ['null', 'string']); - $recipientResolver->setAllowedTypes('phone', ['null', 'string']); - $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { - if (null === $options['email'] && null === $phone) { - throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); - } + return $phone; + }); - return $phone; - }); + /** @var array> $recipientRows */ + $recipientRows = $recipients; - /** @var array> $recipientRows */ - $recipientRows = $recipients; + return array_values(array_map($recipientResolver->resolve(...), $recipientRows)); + }); - return array_values(array_map($recipientResolver->resolve(...), $recipientRows)); + return $notificationResolver->resolve($notification); + } + + /** + * @param array $logs + * + * @return array + */ + private static function resolveLogsOptions(array $logs): array + { + $logsResolver = new OptionsResolver(); + $logsResolver->setDefaults(['database_max_days' => null, 'file_max_days' => null]); + foreach (['database_max_days', 'file_max_days'] as $option) { + $logsResolver->setAllowedTypes($option, ['null', 'int']); + $logsResolver->setAllowedValues($option, static fn (?int $days): bool => null === $days || $days >= 1); + } + + return $logsResolver->resolve($logs); + } + + /** + * @param array $ui + * + * @return UiOptions + */ + private static function resolveUiOptions(array $ui, string $processCode): array + { + $uiResolver = new OptionsResolver(); + $uiResolver->setDefaults( + [ + 'source' => null, + 'target' => null, + 'entrypoint_type' => 'text', + 'ui_launch_mode' => 'modal', + 'constraints' => [], + 'run' => null, + 'default' => [], + 'form' => [], + ] + ); + $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); + // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are + // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 + $uiResolver->setAllowedTypes('default', 'array'); + $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { + $defaultResolver = new OptionsResolver(); + $defaultResolver->setDefaults(['input' => null, 'context' => []]); + $defaultResolver->setAllowedTypes('context', 'array[]'); + $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { + $contextResolver = new OptionsResolver(); + $contextResolver->setRequired(['key', 'value']); + + /** @var array> $contextRows */ + $contextRows = $context; + + return array_map($contextResolver->resolve(...), $contextRows); }); - return $notificationResolver->resolve($notification); + return $defaultResolver->resolve($default); }); - $resolver->setDefault('logs', []); - $resolver->setAllowedTypes('logs', 'array'); - $resolver->setNormalizer('logs', static function (Options $options, array $logs): array { - $logsResolver = new OptionsResolver(); - $logsResolver->setDefaults(['database_max_days' => null, 'file_max_days' => null]); - foreach (['database_max_days', 'file_max_days'] as $option) { - $logsResolver->setAllowedTypes($option, ['null', 'int']); - $logsResolver->setAllowedValues($option, static fn (?int $days): bool => null === $days || $days >= 1); + $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); + $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); + $uiResolver->setAllowedTypes('form', 'array'); + $uiResolver->setNormalizer('form', static fn (Options $options, array $form): array => self::resolveForm($form, $processCode)); + // A Closure message: the deprecation is triggered only when the option is given, once per resolution + $uiResolver->setDeprecated('entrypoint_type', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "entrypoint_type" UI option of the process "%s" is deprecated, use "form.input.type" instead.', $processCode)); + $uiResolver->setDeprecated('default', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "default" UI option of the process "%s" is deprecated, use "form.input.default" and "form.context..default" instead.', $processCode)); + $uiResolver->setDeprecated('constraints', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "constraints" UI option of the process "%s" is deprecated, use "form.constraints" instead.', $processCode)); + + /** @var ResolvedUiOptions $resolved */ + $resolved = $uiResolver->resolve($ui); + // The effective "form" option, the deprecated options folded into it + $resolved['form'] = self::foldDeprecatedOptions($ui, $resolved, $processCode); + + /** @var UiOptions $uiOptions */ + $uiOptions = $resolved; + + return $uiOptions; + } + + /** + * @param array $form + * + * @return ResolvedFormOptions + */ + private static function resolveForm(array $form, string $processCode): array + { + $formResolver = new OptionsResolver(); + $formResolver->setDefaults(['input' => null, 'context' => [], 'allow_extra_context' => true, 'constraints' => []]); + $formResolver->setAllowedTypes('input', ['null', 'array']); + $formResolver->setAllowedTypes('context', 'array'); + $formResolver->setAllowedTypes('allow_extra_context', 'bool'); + $formResolver->setAllowedTypes('constraints', 'array'); + $formResolver->setNormalizer( + 'input', + static fn (Options $options, ?array $input): ?array => null === $input ? null : self::resolveField($input, 'form.input', $processCode, true) + ); + $formResolver->setNormalizer('context', static function (Options $options, array $context) use ($processCode): array { + $fields = []; + foreach ($context as $key => $spec) { + if (\is_int($key)) { + throw new InvalidOptionsException(\sprintf('The context key "%d" of "form.context" of the process "%s" is not supported: purely numeric context keys are cast to integers by PHP.', $key, $processCode)); + } + if ('' === $key || !FormConfigBuilder::isValidName($key)) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key, $processCode)); + } + // "extra" is the child of the free context pairs, "execution_user" is set to the email of the connected user + if (\in_array($key, ['extra', ProcessExecuteMessage::EXECUTION_USER_KEY], true)) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is reserved.', $key, $processCode)); + } + if (null !== $spec && !\is_array($spec)) { + throw new InvalidOptionsException(\sprintf('The launch form field "form.context.%s" of the process "%s" must be an array or null, "%s" given.', $key, $processCode, get_debug_type($spec))); + } + $fields[$key] = self::resolveField($spec ?? [], "form.context.$key", $processCode, false); } - return $logsResolver->resolve($logs); + return $fields; }); - $resolver->setDefault('ui', []); - $resolver->setAllowedTypes('ui', 'array'); - $resolver->setNormalizer('ui', static function (Options $options, array $ui): array { - $uiResolver = new OptionsResolver(); - $uiResolver->setDefaults( - [ - 'source' => null, - 'target' => null, - 'entrypoint_type' => 'text', - 'ui_launch_mode' => 'modal', - 'constraints' => [], - 'run' => null, - 'default' => [], - ] - ); - $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); - // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are - // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 - $uiResolver->setAllowedTypes('default', 'array'); - $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { - $defaultResolver = new OptionsResolver(); - $defaultResolver->setDefaults(['input' => null, 'context' => []]); - $defaultResolver->setAllowedTypes('context', 'array[]'); - $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { - $contextResolver = new OptionsResolver(); - $contextResolver->setRequired(['key', 'value']); - - /** @var array> $contextRows */ - $contextRows = $context; - - return array_map($contextResolver->resolve(...), $contextRows); - }); - - return $defaultResolver->resolve($default); - }); - $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); - $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); + $formResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); - return $uiResolver->resolve($ui); + /** @var ResolvedFormOptions $resolved */ + $resolved = $formResolver->resolve($form); + + return $resolved; + } + + /** + * @param array $spec + * + * @return FormFieldOptions + */ + private static function resolveField(array $spec, string $path, string $processCode, bool $isInput): array + { + $fieldResolver = new OptionsResolver(); + $fieldResolver->setDefaults(['type' => 'text', 'options' => [], 'default' => null]); + $fieldResolver->setAllowedTypes('type', 'string'); + $fieldResolver->setAllowedTypes('options', 'array'); + $fieldResolver->setNormalizer('type', static fn (Options $options, string $type): string => self::resolveFormType($type, $path, $processCode)); + $fieldResolver->setNormalizer('options', static function (Options $options, array $fieldOptions) use ($path, $processCode): array { + foreach (self::UNSUPPORTED_FIELD_OPTIONS as $option) { + if (\array_key_exists($option, $fieldOptions)) { + throw new InvalidOptionsException(\sprintf('The "%s" form option of the launch form field "%s" of the process "%s" is not supported, use "default" for the initial value.', $option, $path, $processCode)); + } + } + if (\array_key_exists('constraints', $fieldOptions)) { + if (!\is_array($fieldOptions['constraints'])) { + throw new InvalidOptionsException(\sprintf('The "constraints" form option of the launch form field "%s" of the process "%s" must be an array of constraints.', $path, $processCode)); + } + $fieldOptions['constraints'] = (new ConstraintLoader())->buildConstraints($fieldOptions['constraints']); + } + + return $fieldOptions; }); - /** - * @var array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} $options - */ - $options = $resolver->resolve($options); - return $options; + /** @var FormFieldOptions $resolved */ + $resolved = $fieldResolver->resolve($spec); + $field = ['type' => $resolved['type'], 'options' => $resolved['options'], 'default' => $resolved['default']]; + + if (is_a($field['type'], Type\FileType::class, true)) { + // A temporary file, removed at the end of the request, would end as a string in the context + if (!$isInput) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field: file fields are only supported for the input.', $path, $processCode)); + } + if (null !== $field['default']) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field, it cannot have a default value.', $path, $processCode)); + } + // The launch action handles a single uploaded file + if (true === ($field['options']['multiple'] ?? false)) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" cannot upload multiple files.', $path, $processCode)); + } + } + + return $field; + } + + /** + * @return class-string> + */ + private static function resolveFormType(string $type, string $path, string $processCode): string + { + $class = self::FORM_TYPE_ALIASES[$type] ?? ltrim($type, '\\'); + if (!class_exists($class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is neither a form type alias (%s) nor an existing class.', $type, $path, $processCode, implode(', ', array_map(static fn (string $alias): string => \sprintf('"%s"', $alias), array_keys(self::FORM_TYPE_ALIASES))))); + } + if (!is_subclass_of($class, FormTypeInterface::class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must implement "%s".', $type, $path, $processCode, FormTypeInterface::class)); + } + // SubmitType implements SubmitButtonTypeInterface, which does not extend ButtonTypeInterface + if (is_subclass_of($class, ButtonTypeInterface::class) || is_subclass_of($class, SubmitButtonTypeInterface::class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is a button type, it holds no data.', $type, $path, $processCode)); + } + + return $class; + } + + /** + * Checks the conflicts between the deprecated UI options and the "form" option, and folds the deprecated options + * into the effective "form" option. + * + * @param array $ui the raw UI options, to know whether an option is given + * @param ResolvedUiOptions $resolved the resolved UI options, before the folding + * + * @return FormOptions + */ + private static function foldDeprecatedOptions(array $ui, array $resolved, string $processCode): array + { + /** @var array $rawForm */ + $rawForm = $ui['form'] ?? []; + /** @var array|null $rawInput */ + $rawInput = $rawForm['input'] ?? null; + /** @var array|null> $rawContext */ + $rawContext = $rawForm['context'] ?? []; + /** @var array $rawDefault */ + $rawDefault = $ui['default'] ?? []; + $form = $resolved['form']; + + if (\array_key_exists('entrypoint_type', $ui) && null !== $rawInput) { + throw new InvalidOptionsException(\sprintf('The "entrypoint_type" and "form.input" UI options of the process "%s" cannot be used together, use "form.input" only.', $processCode)); + } + if (\array_key_exists('constraints', $ui) && \array_key_exists('constraints', $rawForm)) { + throw new InvalidOptionsException(\sprintf('The "constraints" and "form.constraints" UI options of the process "%s" cannot be used together, use "form.constraints" only.', $processCode)); + } + $hasDefaultInput = \array_key_exists('input', $rawDefault); + if ($hasDefaultInput && \array_key_exists('default', $rawInput ?? [])) { + throw new InvalidOptionsException(\sprintf('The default value of the input of the process "%s" is set both in "default.input" and in "form.input.default", use "form.input.default" only.', $processCode)); + } + // The last row of a key wins + $defaultContext = array_column($resolved['default']['context'], 'value', 'key'); + foreach (array_keys($defaultContext) as $key) { + $key = (string) $key; + if (\array_key_exists($key, $form['context'])) { + if (\array_key_exists('default', $rawContext[$key] ?? [])) { + throw new InvalidOptionsException(\sprintf('The default value of the context key "%1$s" of the process "%2$s" is set both in "default.context" and in "form.context.%1$s.default", use "form.context.%1$s.default" only.', $key, $processCode)); + } + } elseif (false === $form['allow_extra_context']) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "default.context" of the process "%s" is not declared in "form.context", and "form.allow_extra_context" is false.', $key, $processCode)); + } + } + + $input = $form['input']; + if (null === $input) { + $isFile = 'file' === $resolved['entrypoint_type']; + $input = ['type' => $isFile ? Type\FileType::class : Type\TextType::class, 'options' => [], 'default' => $isFile ? null : $resolved['default']['input']]; + } elseif ($hasDefaultInput && !\array_key_exists('default', $rawInput ?? []) && !is_a($input['type'], Type\FileType::class, true)) { + $input['default'] = $resolved['default']['input']; + } + + $context = $form['context']; + foreach ($context as $key => $field) { + if (\array_key_exists($key, $defaultContext)) { + $context[$key]['default'] = $defaultContext[$key]; + } + } + + return [ + 'input' => $input, + 'context' => $context, + 'allow_extra_context' => $form['allow_extra_context'], + 'constraints' => \array_key_exists('constraints', $rawForm) ? $form['constraints'] : $resolved['constraints'], + ]; } /** @return ProcessConfiguration[] */ diff --git a/src/Message/ProcessExecuteMessage.php b/src/Message/ProcessExecuteMessage.php index 2c69dbe..cff8991 100644 --- a/src/Message/ProcessExecuteMessage.php +++ b/src/Message/ProcessExecuteMessage.php @@ -15,6 +15,11 @@ readonly class ProcessExecuteMessage { + /** + * The context key set to the email of the connected user when a process is launched from the UI. + */ + public const EXECUTION_USER_KEY = 'execution_user'; + /** * @param array $context */ diff --git a/tests/App/Form/EntityIdType.php b/tests/App/Form/EntityIdType.php new file mode 100644 index 0000000..7f41f11 --- /dev/null +++ b/tests/App/Form/EntityIdType.php @@ -0,0 +1,43 @@ + + */ +class EntityIdType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer( + static fn (mixed $entity): string => $entity instanceof Entity ? (string) $entity->id : '', + static fn (mixed $id): ?Entity => \is_string($id) && '' !== $id ? new Entity((int) $id) : null, + )); + } + + #[\Override] + public function getParent(): string + { + return TextType::class; + } +} diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index 600e425..1d68150 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -93,6 +93,130 @@ clever_age_process: tasks: data: service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_fields: + description: 'Process launched with the UI form, with declared context fields' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + delimiter: + type: choice + default: ',' + options: + choices: { 'Comma': ',', 'Semicolon': ';' } + dry_run: + type: checkbox + options: { required: false } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_fields_date: + description: 'Process launched with the UI form, with declared date fields' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + from: + type: date + options: { widget: single_text, input: datetime_immutable, required: false } + to: + type: date + default: '2026-01-02' + options: { widget: single_text, input: string } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_upload: + description: 'Process launched with the UI form, declared file input and context fields' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + input: + type: file + options: + constraints: + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + delimiter: + type: choice + default: ',' + options: + choices: { 'Comma': ',', 'Semicolon': ';' } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_strict: + description: 'Process launched with the UI form, without free context' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + delimiter: + default: ',' + allow_extra_context: false + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_global_constraints: + description: 'Process launched with the UI form, with global constraints on a declared context field' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + code: ~ + constraints: + - Collection: + fields: + input: ~ + context: + - Collection: + allowExtraFields: true + fields: + code: + - NotBlank: ~ + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_enum: + description: 'Process launched with the UI form, with a pure enum field' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + choice: + type: enum + options: { class: CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_object: + description: 'Process launched with the UI form, with a field returning an object that is not stringable' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + entity: + type: CleverAge\UiProcessBundle\Tests\App\Form\EntityIdType + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' test.upload: description: 'Process launched with the UI form, file input' entry_point: data diff --git a/tests/Controller/Admin/Process/LaunchActionTest.php b/tests/Controller/Admin/Process/LaunchActionTest.php new file mode 100644 index 0000000..7b918c8 --- /dev/null +++ b/tests/Controller/Admin/Process/LaunchActionTest.php @@ -0,0 +1,74 @@ + */ + public array $messages = []; + + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->messages[] = $message; + + return new Envelope($message, $stamps); + } + }; + $requestStack = new RequestStack(); + $action = new class($bus, $requestStack, new ProcessConfigurationsManager(new ProcessConfigurationRegistry([], 'stop')), new AdminContextProvider($requestStack)) extends LaunchAction { + /** + * @param array $context + */ + public function launch(string $processCode, mixed $input, array $context): void + { + $this->dispatch($processCode, $input, $context); + } + + protected function getUser(): User + { + return (new User())->setEmail('user@example.com'); + } + }; + + $action->launch('demo.process', 'data.csv', ['foo' => 'bar', 'execution_user' => 'someone@example.com']); + + self::assertCount(1, $bus->messages); + $message = $bus->messages[0]; + self::assertInstanceOf(ProcessExecuteMessage::class, $message); + self::assertSame(['execution_user' => 'user@example.com', 'foo' => 'bar'], $message->context); + } +} diff --git a/tests/Form/ContextValueNormalizerTest.php b/tests/Form/ContextValueNormalizerTest.php new file mode 100644 index 0000000..43517f4 --- /dev/null +++ b/tests/Form/ContextValueNormalizerTest.php @@ -0,0 +1,104 @@ +normalize($value, 'key')); + } + + /** + * @return iterable + */ + public static function provideNormalizedValues(): iterable + { + yield 'null' => [null, null]; + yield 'true' => [true, true]; + yield 'integer' => [0, 0]; + yield 'float' => [1.5, 1.5]; + yield 'string' => ['a', 'a']; + yield 'nested array' => [ + ['a' => [1 => StringBackedEnum::Comma, 'b' => true], 'c' => PureEnum::Foo], + ['a' => [1 => ',', 'b' => true], 'c' => 'Foo'], + ]; + yield 'string backed enum' => [StringBackedEnum::Comma, ',']; + yield 'int backed enum' => [IntBackedEnum::Ten, 10]; + yield 'pure enum' => [PureEnum::Foo, 'Foo']; + $timezone = new \DateTimeZone('Europe/Paris'); + yield 'immutable date' => [new \DateTimeImmutable('2026-01-02 03:04:05', $timezone), '2026-01-02T03:04:05+01:00']; + yield 'mutable date' => [new \DateTime('2026-01-02 03:04:05', $timezone), '2026-01-02T03:04:05+01:00']; + // The data of a DateIntervalType with its default "input" option + yield 'date interval' => [new \DateInterval('P1Y2M3DT4H5M6S'), 'P1Y2M3DT4H5M6S']; + yield 'date interval with zero parts' => [new \DateInterval('P1D'), 'P0Y0M1DT0H0M0S']; + $inverted = new \DateInterval('PT1H'); + $inverted->invert = 1; + yield 'inverted date interval' => [$inverted, '-P0Y0M0DT1H0M0S']; + yield 'stringable' => [new class implements \Stringable { + public function __toString(): string + { + return 'stringable'; + } + }, 'stringable']; + // The data of an EntityType is the entity itself + yield 'stringable entity' => [new StringableEntity(42), '42']; + } + + public function testUploadedFile(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('The value of "data" is a file ("%s"): file fields are only supported for the input.', UploadedFile::class)); + (new ContextValueNormalizer())->normalize(new UploadedFile(__FILE__, 'data.csv', null, null, true), 'data'); + } + + public function testFile(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The value of "data" is a file ("SplFileInfo"): file fields are only supported for the input.'); + (new ContextValueNormalizer())->normalize(new \SplFileInfo(__FILE__), 'data'); + } + + #[DataProvider('provideInvalidValues')] + public function testInvalidValue(mixed $value, string $name, string $debugType): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', $name, $debugType)); + (new ContextValueNormalizer())->normalize($value, 'k'); + } + + /** + * @return iterable + */ + public static function provideInvalidValues(): iterable + { + yield 'object' => [new \stdClass(), 'k', 'stdClass']; + // The context ['k' => [0 => object]]: the value of the key "k" is normalized with the name "k" + yield 'nested object' => [[0 => new \stdClass()], 'k[0]', 'stdClass']; + yield 'entity' => [new Entity(42), 'k', Entity::class]; + } +} diff --git a/tests/Form/Fixtures/Entity.php b/tests/Form/Fixtures/Entity.php new file mode 100644 index 0000000..ee96581 --- /dev/null +++ b/tests/Form/Fixtures/Entity.php @@ -0,0 +1,24 @@ +id; + } +} diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php index ee2ce68..e673eea 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -18,17 +18,25 @@ use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Validator\ValidatorExtension; use Symfony\Component\Form\FormExtensionInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\PreloadedExtension; use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Validation; #[CoversClass(LaunchType::class)] @@ -51,6 +59,22 @@ protected function getExtensions(): array $registry = new ProcessConfigurationRegistry([ 'text.process' => $this->rawProcess(), 'file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data'), + 'fields.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'form', 'form' => [ + 'input' => ['options' => ['required' => false]], + 'context' => [ + 'delimiter' => [ + 'type' => 'choice', + 'default' => ',', + 'options' => ['label' => 'Delimiter', 'choices' => ['Comma' => ',', 'Semicolon' => ';']], + ], + 'dry_run' => ['type' => 'checkbox', 'default' => false, 'options' => ['required' => false]], + 'limit' => ['type' => 'integer', 'options' => ['constraints' => [['Positive' => null]]]], + ], + ]]], 'data'), + 'strict.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'form', 'form' => [ + 'context' => ['delimiter' => null], + 'allow_extra_context' => false, + ]]]), ], 'stop'); return [ @@ -66,17 +90,29 @@ public function testTextInput(): void self::assertInstanceOf(TextType::class, $this->getInnerType($form->get('input'))); self::assertFalse($form->get('input')->getConfig()->getRequired(), 'The process has no entry point'); - $context = $form->get('context')->getConfig(); - self::assertInstanceOf(CollectionType::class, $context->getType()->getInnerType()); - self::assertSame(ProcessContextType::class, $context->getOption('entry_type')); - self::assertTrue($context->getOption('allow_add')); - self::assertTrue($context->getOption('allow_delete')); - self::assertFalse($context->getOption('required')); + $context = $form->get('context'); + self::assertInstanceOf(FormType::class, $this->getInnerType($context)); + self::assertSame(['extra'], array_keys($context->all())); + $extra = $context->get('extra')->getConfig(); + self::assertInstanceOf(CollectionType::class, $extra->getType()->getInnerType()); + self::assertSame(ProcessContextType::class, $extra->getOption('entry_type')); + self::assertTrue($extra->getOption('allow_add')); + self::assertTrue($extra->getOption('allow_delete')); + self::assertFalse($extra->getOption('required')); + self::assertFalse($extra->getOption('mapped')); + self::assertFalse($extra->getOption('label')); + self::assertSame([], $this->getReservedKeys($extra->getOption('entry_options'))); } public function testFileInput(): void { - $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process']); + $form = null; + $deprecations = $this->collectDeprecations(function () use (&$form): void { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process']); + }); + self::assertInstanceOf(FormInterface::class, $form); + + self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "file.process" is deprecated, use "form.input.type" instead.'], $deprecations); self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); self::assertTrue($form->get('input')->getConfig()->getRequired(), 'The process has an entry point'); @@ -87,10 +123,10 @@ public function testSubmit(): void $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); $form->submit([ 'input' => 'data.csv', - 'context' => [ + 'context' => ['extra' => [ ['key' => 'foo', 'value' => 'bar'], ['key' => 'baz', 'value' => 'qux'], - ], + ]], ]); self::assertTrue($form->isSynchronized()); @@ -117,7 +153,7 @@ public function testDefaultData(): void public function testInvalidContext(): void { $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); - $form->submit(['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => '']]]); + $form->submit(['input' => 'data.csv', 'context' => ['extra' => [['key' => 'foo', 'value' => '']]]]); self::assertTrue($form->isSubmitted()); self::assertFalse($form->isValid()); @@ -126,12 +162,184 @@ public function testInvalidContext(): void public function testContextRowWithoutKey(): void { $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); - $form->submit(['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar'], ['key' => null, 'value' => 'qux']]]); + $form->submit(['input' => 'data.csv', 'context' => ['extra' => [['key' => 'foo', 'value' => 'bar'], ['key' => null, 'value' => 'qux']]]]); // The row without key is left out of the context, and reported as invalid self::assertSame(['foo' => 'bar'], $form->get('context')->getData()); self::assertFalse($form->isValid()); - self::assertCount(1, $form->get('context')->get('1')->get('key')->getErrors()); + self::assertCount(1, $form->get('context')->get('extra')->get('1')->get('key')->getErrors()); + } + + public function testDeclaredFields(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $context = $form->get('context'); + + self::assertSame(['delimiter', 'dry_run', 'limit', 'extra'], array_keys($context->all())); + $delimiter = $context->get('delimiter')->getConfig(); + self::assertInstanceOf(ChoiceType::class, $delimiter->getType()->getInnerType()); + self::assertSame(['Comma' => ',', 'Semicolon' => ';'], $delimiter->getOption('choices')); + self::assertSame('Delimiter', $delimiter->getOption('label')); + self::assertInstanceOf(CheckboxType::class, $this->getInnerType($context->get('dry_run'))); + self::assertInstanceOf(IntegerType::class, $this->getInnerType($context->get('limit'))); + $extra = $context->get('extra')->getConfig(); + self::assertSame('Context (key/value)', $extra->getOption('label')); + self::assertSame(['delimiter', 'dry_run', 'limit'], $this->getReservedKeys($extra->getOption('entry_options'))); + } + + public function testSubmitDeclaredAndExtraContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->submit(['input' => '', 'context' => [ + 'delimiter' => ';', + 'dry_run' => '1', + 'limit' => '3', + 'extra' => [['key' => 'k2', 'value' => 'v2']], + ]]); + + self::assertTrue($form->isValid()); + self::assertSame(['delimiter' => ';', 'dry_run' => true, 'limit' => 3, 'k2' => 'v2'], $form->get('context')->getData()); + } + + public function testExtraContextRowWithADeclaredKey(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->submit(['context' => [ + 'delimiter' => ';', + 'extra' => [['key' => 'k2', 'value' => 'v2'], ['key' => 'delimiter', 'value' => 'zz']], + ]]); + + self::assertFalse($form->isValid()); + $errors = $form->get('context')->get('extra')->get('1')->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "delimiter" is a field of the launch form, use this field instead.', $errors[0]->getMessage()); + self::assertCount(0, $form->get('context')->get('delimiter')->getErrors()); + } + + #[TestWith(['text.process'])] + #[TestWith(['fields.process'])] + public function testExtraContextRowWithTheExecutionUserKey(string $processCode): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => $processCode]); + $form->submit(['context' => ['extra' => [['key' => 'execution_user', 'value' => 'someone@example.com']]]]); + + self::assertFalse($form->isValid()); + $errors = $form->get('context')->get('extra')->get('0')->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "execution_user" is reserved: it is set to the email of the connected user.', $errors[0]->getMessage()); + } + + public function testFieldConstraint(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->submit(['context' => ['delimiter' => ',', 'limit' => '-1']]); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get('context')->get('limit')->getErrors()); + } + + public function testGlobalConstraintOnADeclaredField(): void + { + $form = $this->factory->create(LaunchType::class, null, [ + 'process_code' => 'fields.process', + 'constraints' => [new Collection( + fields: ['context' => new Collection(fields: ['delimiter' => new Choice(choices: [','])], allowExtraFields: true)], + allowExtraFields: true, + )], + ]); + $form->submit(['context' => ['delimiter' => ';']]); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get('context')->get('delimiter')->getErrors()); + } + + public function testExtraContextNotAllowed(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'strict.process']); + + self::assertSame(['delimiter'], array_keys($form->get('context')->all())); + + $form->submit(['context' => ['delimiter' => ',', 'extra' => [['key' => 'k', 'value' => 'v']]]]); + self::assertFalse($form->isValid()); + } + + /** + * Defensive: free keys in the data of a process without free context are not turned into rows, and are left out + * of the submitted context. + */ + public function testFreeDefaultsWithoutExtraContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'strict.process']); + $form->setData(['input' => null, 'context' => ['delimiter' => ';', 'foo' => 'bar']]); + + self::assertSame(';', $form->get('context')->get('delimiter')->getData()); + self::assertFalse($form->get('context')->has('extra')); + + $form->submit(['context' => ['delimiter' => ',']]); + self::assertTrue($form->isValid()); + self::assertSame(['delimiter' => ','], $form->get('context')->getData()); + } + + public function testDefaultsOfDeclaredAndExtraContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->setData(['input' => null, 'context' => ['delimiter' => ';', 'dry_run' => true, 'limit' => null, 'foo' => 'bar']]); + + $context = $form->get('context'); + self::assertSame(';', $context->get('delimiter')->getData()); + self::assertSame([['key' => 'foo', 'value' => 'bar']], $context->get('extra')->getData()); + + $form->submit(['context' => ['delimiter' => ',', 'extra' => []]]); + self::assertTrue($form->isValid()); + self::assertSame(['delimiter' => ',', 'dry_run' => false, 'limit' => null], $context->getData()); + } + + public function testRequiredOptionOfTheDeclaredFields(): void + { + $view = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process'])->createView(); + $context = $view->children['context']; + + self::assertFalse($context->vars['required'], 'The context group is not rendered as required'); + self::assertTrue($context->children['delimiter']->vars['required'], 'Symfony default'); + self::assertTrue($context->children['limit']->vars['required'], 'Symfony default'); + self::assertFalse($context->children['dry_run']->vars['required'], 'The required option of the field is configured'); + self::assertFalse($context->children['extra']->vars['required'], 'The free context pairs are not required'); + } + + public function testRequiredOptionOfTheInput(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + + self::assertFalse($form->get('input')->getConfig()->getRequired(), 'The required option of the input is configured'); + } + + /** + * The UI options already resolved by the caller are not resolved again. + */ + public function testResolvedUiOptions(): void + { + $registry = new ProcessConfigurationRegistry(['file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data')], 'stop'); + $uiOptions = null; + $resolutionDeprecations = $this->collectDeprecations(static function () use ($registry, &$uiOptions): void { + $uiOptions = (new ProcessConfigurationsManager($registry))->getUiOptions('file.process'); + }); + self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "file.process" is deprecated, use "form.input.type" instead.'], $resolutionDeprecations); + self::assertIsArray($uiOptions); + + $form = null; + $deprecations = $this->collectDeprecations(function () use (&$form, $uiOptions): void { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process', 'ui_options' => $uiOptions]); + }); + + self::assertSame([], $deprecations); + self::assertInstanceOf(FormInterface::class, $form); + self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); + } + + public function testUiOptionsMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process', 'ui_options' => 'form']); } public function testProcessCodeIsRequired(): void @@ -140,6 +348,33 @@ public function testProcessCodeIsRequired(): void $this->factory->create(LaunchType::class); } + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private function getReservedKeys(mixed $entryOptions): mixed + { + self::assertIsArray($entryOptions); + + return $entryOptions['reserved_keys'] ?? null; + } + /** * @param FormInterface $form */ diff --git a/tests/Form/Type/ProcessContextTypeTest.php b/tests/Form/Type/ProcessContextTypeTest.php index 1484ebb..cefb1ff 100644 --- a/tests/Form/Type/ProcessContextTypeTest.php +++ b/tests/Form/Type/ProcessContextTypeTest.php @@ -20,6 +20,8 @@ use Symfony\Component\Form\Extension\Validator\ValidatorExtension; use Symfony\Component\Form\FormExtensionInterface; use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Validation; @@ -51,9 +53,11 @@ public function testFields(): void self::assertSame(['placeholder' => $name], $config->getOption('attr')); $constraints = $config->getOption('constraints'); self::assertIsArray($constraints); - self::assertCount(1, $constraints); self::assertInstanceOf(NotBlank::class, $constraints[0]); } + // The format of the key is checked + self::assertCount(2, (array) $form->get('key')->getConfig()->getOption('constraints')); + self::assertCount(1, (array) $form->get('value')->getConfig()->getOption('constraints')); } public function testSubmitValidData(): void @@ -76,4 +80,104 @@ public function testKeyAndValueAreRequired(string $key, string $value, string $i self::assertFalse($form->isValid()); self::assertCount(1, $form->get($invalidField)->getErrors()); } + + public function testNoReservedKeysByDefault(): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => 'delimiter', 'value' => ';']); + + self::assertSame([], $form->getConfig()->getOption('reserved_keys')); + self::assertTrue($form->isValid()); + } + + public function testReservedKey(): void + { + $form = $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => ['delimiter']]); + $form->submit(['key' => 'delimiter', 'value' => ';']); + + self::assertFalse($form->isValid()); + $errors = $form->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "delimiter" is a field of the launch form, use this field instead.', $errors[0]->getMessage()); + self::assertCount(0, $form->get('value')->getErrors()); + } + + #[TestWith(['other'])] + #[TestWith(['Delimiter'])] + public function testKeyIsNotReserved(string $key): void + { + $form = $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => ['delimiter']]); + $form->submit(['key' => $key, 'value' => ';']); + + self::assertTrue($form->isValid()); + } + + public function testReservedKeysMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => 'delimiter']); + } + + #[TestWith(['foo'])] + #[TestWith(['Delimiter'])] + #[TestWith(['a-b:c'])] + #[TestWith(['_x'])] + #[TestWith(['1a'])] + #[TestWith(['dry_run'])] + public function testValidKeyFormat(string $key): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => $key, 'value' => 'bar']); + + self::assertTrue($form->isValid()); + } + + #[TestWith(['zz)|x|(?:zz'])] + #[TestWith(['a/b'])] + #[TestWith(['a b'])] + #[TestWith(['{{x}}'])] + #[TestWith(['-a'])] + #[TestWith(['123'])] + public function testInvalidKeyFormat(string $key): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => $key, 'value' => 'bar']); + + self::assertFalse($form->isValid()); + $errors = $form->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame( + \sprintf('The context key "%s" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.', $key), + $errors[0]->getMessage() + ); + } + + public function testEmptyKey(): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => '', 'value' => 'bar']); + + self::assertFalse($form->isValid()); + // Reported by the NotBlank constraint only + self::assertCount(1, $form->get('key')->getErrors()); + } + + public function testKeyConstraints(): void + { + $form = $this->factory->create(ProcessContextType::class, null, [ + 'key_constraints' => [new Choice(choices: ['execution_user'], message: 'Reserved key {{ value }}.', match: false)], + ]); + $form->submit(['key' => 'execution_user', 'value' => 'bar']); + + self::assertFalse($form->isValid()); + $errors = $form->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('Reserved key "execution_user".', $errors[0]->getMessage()); + } + + public function testKeyConstraintsMustBeConstraints(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(ProcessContextType::class, null, ['key_constraints' => ['NotBlank']]); + } } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 95d2a9a..4253b9c 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -26,6 +26,7 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\ContextValueNormalizer; use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; @@ -35,6 +36,8 @@ use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\Entity; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; use CleverAge\UiProcessBundle\Twig\Extension\MD5Extension; use CleverAge\UiProcessBundle\Twig\Extension\ProcessExecutionExtension; @@ -43,6 +46,7 @@ use CleverAge\UiProcessBundle\Twig\Runtime\ProcessExtensionRuntime; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; +use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\HttpFoundation\File\UploadedFile; #[CoversClass(LaunchAction::class)] @@ -51,6 +55,7 @@ #[UsesClass(User::class)] #[UsesClass(LaunchType::class)] #[UsesClass(ProcessContextType::class)] +#[UsesClass(ContextValueNormalizer::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(ProcessExecuteMessage::class)] #[UsesClass(ProcessExecutionRepository::class)] @@ -103,13 +108,13 @@ public function testLaunchWithForm(): void self::assertSame([], $this->getDispatchedMessages()); // Default values of the UI options self::assertSame('default input', $crawler->filter('input[name="launch[input]"]')->attr('value')); - self::assertSame('key1', $crawler->filter('input[name="launch[context][0][key]"]')->attr('value')); + self::assertSame('key1', $crawler->filter('input[name="launch[context][extra][0][key]"]')->attr('value')); $form = $crawler->selectButton('Launch')->form(); /** @var array> $values */ $values = $form->getPhpValues(); $values['launch']['input'] = 'my input'; - $values['launch']['context'] = [['key' => 'key1', 'value' => 'value1'], ['key' => 'key2', 'value' => 'value2']]; + $values['launch']['context'] = ['extra' => [['key' => 'key1', 'value' => 'value1'], ['key' => 'key2', 'value' => 'value2']]]; $this->client->request($form->getMethod(), $form->getUri(), $values); self::assertResponseRedirects(); @@ -120,6 +125,29 @@ public function testLaunchWithForm(): void self::assertSame(['execution_user' => 'user@example.com', 'key1' => 'value1', 'key2' => 'value2'], $messages[0]->context); } + public function testLaunchWithDeclaredFields(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_fields'); + + self::assertResponseIsSuccessful(); + // Default value of the declared field + self::assertSame(',', $crawler->filter('select[name="launch[context][delimiter]"] option[selected]')->attr('value')); + + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['delimiter' => ';', 'dry_run' => '1', 'extra' => [['key' => 'k', 'value' => 'v']]]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame('test.form_fields', $messages[0]->code); + self::assertSame(['execution_user' => 'user@example.com', 'delimiter' => ';', 'dry_run' => true, 'k' => 'v'], $messages[0]->context); + } + public function testLaunchWithFileUpload(): void { $this->login(); @@ -166,7 +194,7 @@ public function testContextRowWithoutKey(): void $form = $crawler->selectButton('Launch')->form(); /** @var array> $values */ $values = $form->getPhpValues(); - $values['launch']['context'] = [['key' => 'key1', 'value' => 'value1'], ['key' => '', 'value' => 'value2']]; + $values['launch']['context'] = ['extra' => [['key' => 'key1', 'value' => 'value1'], ['key' => '', 'value' => 'value2']]]; $this->client->request($form->getMethod(), $form->getUri(), $values); // The form is displayed again with the error (LaunchAction renders an invalid form with a 200) @@ -175,6 +203,356 @@ public function testContextRowWithoutKey(): void self::assertSame([], $this->getDispatchedMessages()); } + /** + * The deprecated format is unchanged: same initial values, same dispatched input and context (only the HTML names + * of the free rows change, "launch[context][extra][N]"). + */ + public function testDeprecatedFormatIsUnchanged(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form'); + self::assertResponseIsSuccessful(); + self::assertSame('value1', $crawler->filter('input[name="launch[context][extra][0][value]"]')->attr('value')); + self::assertCount(0, $crawler->filter('[name^="launch[context][0]"]')); + + // Submitted with its initial values + $form = $crawler->selectButton('Launch')->form(); + $this->client->request($form->getMethod(), $form->getUri(), $form->getPhpValues()); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame('default input', $messages[0]->input); + self::assertSame(['execution_user' => 'user@example.com', 'key1' => 'value1'], $messages[0]->context); + } + + /** + * The deprecated global "constraints" option still applies to the free rows of the context. + */ + public function testDeprecatedConstraintsOnTheFreeContext(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_constraints'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + + $values['launch']['context'] = ['extra' => [['key' => 'key1', 'value' => '']]]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.invalid-feedback', 'This value should not be blank.'); + self::assertSame([], $this->getDispatchedMessages()); + + $values['launch']['context'] = ['extra' => [['key' => 'key1', 'value' => 'value1']]]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame(['execution_user' => 'user@example.com', 'key1' => 'value1'], $messages[0]->context); + } + + public function testInvalidChoice(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_fields'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['delimiter' => '|']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame('The selected choice is invalid.', $this->getFieldError($crawler, 'launch_context_delimiter')); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testExtraContextRowWithADeclaredKey(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_fields'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['delimiter' => ';', 'extra' => [['key' => 'delimiter', 'value' => 'zz']]]; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame( + 'The context key "delimiter" is a field of the launch form, use this field instead.', + $this->getFieldError($crawler, 'launch_context_extra_0_key') + ); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testExtraContextRowWithTheExecutionUserKey(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['extra' => [['key' => 'execution_user', 'value' => 'someone@example.com']]]; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame( + 'The context key "execution_user" is reserved: it is set to the email of the connected user.', + $this->getFieldError($crawler, 'launch_context_extra_0_key') + ); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testDateFields(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_fields_date'); + self::assertResponseIsSuccessful(); + // A string default value, with "input: string" + self::assertSame('2026-01-02', $crawler->filter('input[name="launch[context][to]"]')->attr('value')); + self::assertSame('', (string) $crawler->filter('input[name="launch[context][from]"]')->attr('value')); + + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['from' => '2026-03-04', 'to' => '2026-05-06']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame( + [ + 'execution_user' => 'user@example.com', + // A DateTimeImmutable (in the default timezone of PHP), converted to the ATOM format + 'from' => (new \DateTimeImmutable('2026-03-04 00:00:00'))->format(\DATE_ATOM), + 'to' => '2026-05-06', + ], + $messages[0]->context + ); + } + + public function testEmptyDeclaredField(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_fields_date'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['from' => '', 'to' => '2026-05-06']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + // The key of an empty declared field is in the context + self::assertSame(['execution_user' => 'user@example.com', 'from' => null, 'to' => '2026-05-06'], $messages[0]->context); + } + + public function testDeclaredFileInput(): void + { + $this->login(); + $file = (string) tempnam(sys_get_temp_dir(), 'upload'); + // Detected as "text/csv" by fileinfo: the "extensions" option of the File constraint also checks the mime type + file_put_contents($file, "id,name,city\n1,foo,bar\n2,baz,qux\n3,a,b\n"); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_upload'); + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('input[type="file"][name="launch[input]"]')); + $form = $crawler->selectButton('Launch')->form(); + + // An extension refused by the File constraint of the input + $crawler = $this->client->request( + $form->getMethod(), + $form->getUri(), + $form->getPhpValues(), + ['launch' => ['input' => new UploadedFile($file, 'data.txt', 'text/plain', null, true)]] + ); + self::assertResponseIsSuccessful(); + self::assertStringContainsString('"txt"', $this->getFieldError($crawler, 'launch_input')); + self::assertSame([], $this->getDispatchedMessages()); + + $this->client->request( + $form->getMethod(), + $form->getUri(), + $form->getPhpValues(), + ['launch' => ['input' => new UploadedFile($file, 'data.csv', 'text/csv', null, true)]] + ); + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertIsString($messages[0]->input); + self::assertStringEndsWith('.csv', $messages[0]->input); + self::assertSame("id,name,city\n1,foo,bar\n2,baz,qux\n3,a,b\n", file_get_contents($messages[0]->input)); + // The default value of the declared field + self::assertSame(['execution_user' => 'user@example.com', 'delimiter' => ','], $messages[0]->context); + unlink($file); + } + + /** + * A CSV file with a semicolon delimiter is detected as "text/plain": the File constraint of the input lists the + * accepted mime types of the "csv" extension. + */ + public function testDeclaredFileInputWithASemicolonDelimiter(): void + { + $this->login(); + $file = (string) tempnam(sys_get_temp_dir(), 'upload'); + file_put_contents($file, "id;name;city\n1;foo;bar\n2;baz;qux\n"); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_upload'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['delimiter' => ';']; + $this->client->request( + $form->getMethod(), + $form->getUri(), + $values, + ['launch' => ['input' => new UploadedFile($file, 'data.csv', 'text/csv', null, true)]] + ); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertIsString($messages[0]->input); + self::assertSame("id;name;city\n1;foo;bar\n2;baz;qux\n", file_get_contents($messages[0]->input)); + self::assertSame(['execution_user' => 'user@example.com', 'delimiter' => ';'], $messages[0]->context); + unlink($file); + } + + public function testExtraContextNotAllowed(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_strict'); + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('input[name="launch[context][delimiter]"]')); + self::assertCount(0, $crawler->filter('[name^="launch[context][extra]"]')); + self::assertCount(0, $crawler->filter('[data-ea-collection-field]')); + + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + + // A free row submitted anyway + $values['launch']['context'] = ['delimiter' => ';', 'extra' => [['key' => 'k', 'value' => 'v']]]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.invalid-feedback', 'This form should not contain extra fields.'); + self::assertSame([], $this->getDispatchedMessages()); + + $values['launch']['context'] = ['delimiter' => ';']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame(['execution_user' => 'user@example.com', 'delimiter' => ';'], $messages[0]->context); + } + + public function testGlobalConstraintOnADeclaredField(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_global_constraints'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['code' => '']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + // The violation of the "form.constraints" option is displayed under the declared field + self::assertSame('This value should not be blank.', $this->getFieldError($crawler, 'launch_context_code')); + self::assertSame([], $this->getDispatchedMessages()); + + $values['launch']['context'] = ['code' => 'ABC']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame(['execution_user' => 'user@example.com', 'code' => 'ABC'], $messages[0]->context); + } + + public function testPureEnumField(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_enum'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['choice' => $crawler->filter('select[name="launch[context][choice]"] option')->attr('value')]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + // The name of the case of the pure enum + self::assertSame(['execution_user' => 'user@example.com', 'choice' => PureEnum::Foo->name], $messages[0]->context); + } + + /** + * A field whose data is an object that cannot be converted (as an EntityType on an entity that is not + * \Stringable): the launch fails with a LogicException (a 500), nothing is dispatched. + */ + public function testContextValueThatCannotBeConverted(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_object'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['entity' => '42']; + + $this->client->catchExceptions(false); + try { + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::fail('A LogicException is expected'); + } catch (\LogicException $exception) { + self::assertSame( + \sprintf('The value of "entity" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', Entity::class), + $exception->getMessage() + ); + } + self::assertSame([], $this->getDispatchedMessages()); + + // Caught by the kernel: a 500 + $this->client->catchExceptions(true); + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseStatusCodeSame(500); + self::assertSame([], $this->getDispatchedMessages()); + } + + /** + * The UI options are resolved once per launch request: the deprecations of the old format are triggered once. + */ + public function testDeprecationsOfTheLaunchForm(): void + { + $this->login(); + + $deprecations = $this->collectDeprecations(fn () => $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form')); + self::assertResponseIsSuccessful(); + self::assertSame([ + 'Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "test.form" is deprecated, use "form.input.type" instead.', + 'Since cleverage/ui-process-bundle 3.3: The "default" UI option of the process "test.form" is deprecated, use "form.input.default" and "form.context..default" instead.', + ], $deprecations); + + // A process with the "form" UI option triggers no deprecation + $deprecations = $this->collectDeprecations(fn () => $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_fields')); + self::assertResponseIsSuccessful(); + self::assertSame([], $deprecations); + } + public function testMissingProcessCode(): void { $this->login(); @@ -209,4 +587,36 @@ public function testDirectAccessRedirectsToTheDashboard(): void self::assertResponseIsSuccessful(); self::assertCount(1, $crawler->filter('input[name="launch[input]"]')); } + + /** + * The error message displayed under the form field with the given id. + */ + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private function getFieldError(Crawler $crawler, string $id): string + { + $errors = $crawler->filter('#'.$id)->closest('.form-widget')?->filter('.invalid-feedback'); + self::assertNotNull($errors); + self::assertCount(1, $errors, \sprintf('One error is expected under the field "%s".', $id)); + + return trim($errors->text()); + } } diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index f723f44..ba829dd 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -42,8 +42,13 @@ use CleverAge\UiProcessBundle\Validator\CronExpressionValidator; use CleverAge\UiProcessBundle\Validator\EveryExpressionValidator; use CleverAge\UiProcessBundle\Validator\IsValidProcessCodeValidator; +use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\Form\FormFactoryInterface; #[CoversClass(ProcessScheduleCrudController::class)] #[UsesClass(EnumField::class)] @@ -182,6 +187,132 @@ public function testDisable(): void self::assertDoesNotMatchRegularExpression('/\d{4}-\d{2}-\d{2}T04:00:00/', $crawler->filter('table')->text()); } + /** + * The "execution_user" context key is reserved in the schedules too, as in the launch form. + */ + public function testExecutionUserIsAReservedContextKey(): void + { + $controller = static::getContainer()->get(ProcessScheduleCrudController::class); + self::assertInstanceOf(ProcessScheduleCrudController::class, $controller); + $entryOptions = null; + foreach ($controller->configureFields(Crud::PAGE_EDIT) as $field) { + if ($field instanceof FieldInterface && 'context' === $field->getAsDto()->getProperty()) { + $entryOptions = $field->getAsDto()->getFormTypeOptions()['entry_options'] ?? null; + } + } + self::assertIsArray($entryOptions); + /** @var array $rowOptions */ + $rowOptions = $entryOptions + ['csrf_protection' => false]; + + $formFactory = static::getContainer()->get('form.factory'); + self::assertInstanceOf(FormFactoryInterface::class, $formFactory); + $row = $formFactory->create(ProcessContextType::class, null, $rowOptions); + $row->submit(['key' => 'execution_user', 'value' => 'someone@example.com']); + + self::assertFalse($row->isValid()); + $errors = $row->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "execution_user" is reserved: it is set to the email of the connected user.', $errors[0]->getMessage()); + } + + /** + * The keys of the context rows follow the same rules as in the launch form. + */ + #[TestWith(['a b', 'The context key "a b" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.'])] + #[TestWith(['execution_user', 'The context key "execution_user" is reserved: it is set to the email of the connected user.'])] + public function testCreateWithAnInvalidContextKey(string $key, string $error): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process/process-schedule/new'); + $form = $crawler->selectButton('Create')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['ProcessSchedule']['process'] = 'test.process'; + $values['ProcessSchedule']['type'] = 'cron'; + $values['ProcessSchedule']['expression'] = '*/5 * * * *'; + $values['ProcessSchedule']['context'] = [['key' => 'delimiter', 'value' => ';'], ['key' => $key, 'value' => 'value']]; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseStatusCodeSame(422); + self::assertSame([$error], $this->getFieldErrors($crawler, 'ProcessSchedule_context_1_key')); + self::assertSame([], $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key')); + self::assertSame([], $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll()); + } + + public function testCreateWithAValidContextKey(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process/process-schedule/new'); + $form = $crawler->selectButton('Create')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['ProcessSchedule']['process'] = 'test.process'; + $values['ProcessSchedule']['type'] = 'cron'; + $values['ProcessSchedule']['expression'] = '*/5 * * * *'; + $values['ProcessSchedule']['context'] = [['key' => 'delimiter', 'value' => ';']]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $this->getEntityManager()->clear(); + $schedules = $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll(); + self::assertCount(1, $schedules); + self::assertSame([['key' => 'delimiter', 'value' => ';']], $schedules[0]->getContext()); + } + + /** + * A schedule saved before the rules on the context keys: it is saved again only once its key is fixed, and its + * "enabled" toggle of the index page is not affected. + */ + public function testScheduleWithAContextKeyNowInvalid(): void + { + $this->login(); + $schedule = $this->createSchedule(ProcessScheduleType::CRON, '0 4 * * *'); + $schedule->setContext([['key' => 'execution_user', 'value' => 'someone@example.com']]); + $this->getEntityManager()->flush(); + $id = $schedule->getId(); + + // The toggle of the index page (an AJAX PATCH request): no form validation + $crawler = $this->client->request('GET', '/process/process-schedule'); + self::assertResponseIsSuccessful(); + $toggleUrl = (string) $crawler->filter('[data-toggle-url]')->attr('data-toggle-url'); + self::assertStringContainsString('fieldName=enabled', $toggleUrl); + $this->client->xmlHttpRequest('PATCH', $toggleUrl.'&newValue=false'); + self::assertResponseIsSuccessful(); + $this->getEntityManager()->clear(); + $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); + self::assertNotNull($schedule); + self::assertFalse($schedule->isEnabled()); + self::assertSame([['key' => 'execution_user', 'value' => 'someone@example.com']], $schedule->getContext()); + + // Saved unchanged: refused + $crawler = $this->client->request('GET', '/process/process-schedule/'.$id.'/edit'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Save changes')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['ProcessSchedule']['expression'] = '0 5 * * *'; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseStatusCodeSame(422); + self::assertSame( + ['The context key "execution_user" is reserved: it is set to the email of the connected user.'], + $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key') + ); + $this->getEntityManager()->clear(); + self::assertSame('0 4 * * *', $this->getEntityManager()->find(ProcessSchedule::class, $id)?->getExpression()); + + // Saved once the key is fixed + $values['ProcessSchedule']['context'] = [['key' => 'user', 'value' => 'someone@example.com']]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseRedirects(); + $this->getEntityManager()->clear(); + $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); + self::assertNotNull($schedule); + self::assertSame('0 5 * * *', $schedule->getExpression()); + self::assertSame([['key' => 'user', 'value' => 'someone@example.com']], $schedule->getContext()); + } + public function testEntityFqcn(): void { self::assertSame(ProcessSchedule::class, ProcessScheduleCrudController::getEntityFqcn()); @@ -198,4 +329,17 @@ private function createSchedule(ProcessScheduleType $type, string $expression): return $schedule; } + + /** + * The error messages displayed under the form field with the given id. + * + * @return list + */ + private function getFieldErrors(Crawler $crawler, string $id): array + { + $widget = $crawler->filter('#'.$id)->closest('.form-widget'); + self::assertNotNull($widget, \sprintf('No field "%s".', $id)); + + return $widget->filter('.invalid-feedback')->each(static fn (Crawler $error): string => trim($error->text())); + } } diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 82141d5..269b868 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -14,13 +14,23 @@ namespace CleverAge\UiProcessBundle\Tests\Manager; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; +use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\Extension\Core\Type; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface as OptionsResolverException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; @@ -28,10 +38,54 @@ use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; +/** + * @phpstan-import-type UiOptions from ProcessConfigurationsManager + */ #[CoversClass(ProcessConfigurationsManager::class)] #[UsesClass(NotificationTrigger::class)] class ProcessConfigurationsManagerTest extends TestCase { + /** + * The form type aliases of the launch form fields, in their documented order. + */ + private const FORM_TYPE_ALIASES = [ + 'text' => TextType::class, + 'textarea' => TextareaType::class, + 'email' => Type\EmailType::class, + 'password' => Type\PasswordType::class, + 'search' => Type\SearchType::class, + 'url' => Type\UrlType::class, + 'tel' => Type\TelType::class, + 'hidden' => Type\HiddenType::class, + 'integer' => Type\IntegerType::class, + 'number' => Type\NumberType::class, + 'money' => Type\MoneyType::class, + 'percent' => Type\PercentType::class, + 'range' => Type\RangeType::class, + 'checkbox' => Type\CheckboxType::class, + 'radio' => Type\RadioType::class, + 'choice' => ChoiceType::class, + 'enum' => Type\EnumType::class, + 'country' => Type\CountryType::class, + 'currency' => Type\CurrencyType::class, + 'language' => Type\LanguageType::class, + 'locale' => Type\LocaleType::class, + 'timezone' => Type\TimezoneType::class, + 'date' => Type\DateType::class, + 'datetime' => Type\DateTimeType::class, + 'time' => Type\TimeType::class, + 'birthday' => Type\BirthdayType::class, + 'week' => Type\WeekType::class, + 'dateinterval' => Type\DateIntervalType::class, + 'color' => Type\ColorType::class, + 'file' => FileType::class, + 'uuid' => Type\UuidType::class, + 'ulid' => Type\UlidType::class, + 'collection' => Type\CollectionType::class, + 'repeated' => Type\RepeatedType::class, + 'entity' => EntityType::class, + ]; + public function testPublicAndPrivateProcesses(): void { $manager = $this->createManager([ @@ -60,9 +114,9 @@ public function testUiOptionsOfAnUnknownProcess(): void public function testDefaultUiOptions(): void { - $options = $this->createManager(['test.process' => $this->rawProcess()])->getUiOptions('test.process'); + [$options, $deprecations] = $this->resolveUi([]); - self::assertNotNull($options); + self::assertSame([], $deprecations); self::assertNull($options['source']); self::assertNull($options['target']); self::assertSame('text', $options['entrypoint_type']); @@ -72,30 +126,40 @@ public function testDefaultUiOptions(): void $run = $options['run']; self::assertNull($run); self::assertSame(['input' => null, 'context' => []], $options['default']); + self::assertSame( + [ + 'input' => ['type' => TextType::class, 'options' => [], 'default' => null], + 'context' => [], + 'allow_extra_context' => true, + 'constraints' => [], + ], + $options['form'] + ); } public function testConfiguredUiOptions(): void { - $manager = $this->createManager([ - 'test.process' => $this->rawProcess([ - 'ui' => [ - 'source' => 'source', - 'target' => 'target', - 'entrypoint_type' => 'file', - 'ui_launch_mode' => 'form', - 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], - 'run' => true, - 'default' => [ - 'input' => 'data.csv', - 'context' => [['key' => 'foo', 'value' => 'bar']], - ], - ], - ]), + [$options, $deprecations] = $this->resolveUi([ + 'source' => 'source', + 'target' => 'target', + 'entrypoint_type' => 'file', + 'ui_launch_mode' => 'form', + 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], + 'run' => true, + 'default' => [ + 'input' => 'data.csv', + 'context' => [['key' => 'foo', 'value' => 'bar']], + ], ]); - $options = $manager->getUiOptions('test.process'); - - self::assertNotNull($options); + self::assertSame( + [ + self::deprecation('entrypoint_type', 'form.input.type'), + self::deprecation('constraints', 'form.constraints'), + self::deprecation('default', 'form.input.default" and "form.context..default'), + ], + $deprecations + ); self::assertSame('source', $options['source']); self::assertSame('target', $options['target']); self::assertSame('file', $options['entrypoint_type']); @@ -108,17 +172,18 @@ public function testConfiguredUiOptions(): void self::assertInstanceOf(NotBlank::class, $options['constraints'][0]); self::assertInstanceOf(Length::class, $options['constraints'][1]); self::assertSame(10, $options['constraints'][1]->max); + // The deprecated options are folded into the effective "form" option + self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $options['form']['input']); + self::assertSame([], $options['form']['context']); + self::assertTrue($options['form']['allow_extra_context']); + self::assertSame($options['constraints'], $options['form']['constraints']); } public function testDefaultWithoutContext(): void { - $manager = $this->createManager([ - 'test.process' => $this->rawProcess(['ui' => ['default' => ['input' => 'data.csv']]]), - ]); - - $options = $manager->getUiOptions('test.process'); + [$options, $deprecations] = $this->resolveUi(['default' => ['input' => 'data.csv']]); - self::assertNotNull($options); + self::assertSame([self::deprecation('default', 'form.input.default" and "form.context..default')], $deprecations); self::assertSame(['input' => 'data.csv', 'context' => []], $options['default']); } @@ -129,10 +194,8 @@ public function testDefaultWithoutContext(): void #[DataProvider('provideInvalidDefault')] public function testInvalidDefault(array $default, string $exception): void { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => $default]])]); - $this->expectException($exception); - $manager->getUiOptions('test.process'); + $this->resolveUi(['default' => $default]); } /** @@ -152,20 +215,578 @@ public static function provideInvalidDefault(): iterable public function testDefaultIsNotAnArray(): void { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => 'data.csv']])]); - $this->expectException(InvalidOptionsException::class); - $manager->getUiOptions('test.process'); + $this->resolveUi(['default' => 'data.csv']); } public function testInvalidEntrypointType(): void { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'csv']])]); + $this->expectException(InvalidOptionsException::class); + $this->resolveUi(['entrypoint_type' => 'csv']); + } + + public function testFormInputAlias(): void + { + [$options, $deprecations] = $this->resolveUi(['form' => ['input' => ['type' => 'file', 'options' => ['label' => 'CSV file']]]]); + + self::assertSame([], $deprecations); + self::assertSame(['type' => FileType::class, 'options' => ['label' => 'CSV file'], 'default' => null], $options['form']['input']); + } + + /** + * @param class-string $class + */ + #[DataProvider('provideFormTypeAliases')] + public function testFormTypeAlias(string $alias, string $class): void + { + [$options] = $this->resolveUi(['form' => ['input' => ['type' => $alias]]]); + + self::assertSame($class, $options['form']['input']['type']); + } + + /** + * @return iterable + */ + public static function provideFormTypeAliases(): iterable + { + foreach (self::FORM_TYPE_ALIASES as $alias => $class) { + yield $alias => [$alias, $class]; + } + } + + /** + * @param class-string $class + */ + #[TestWith([ChoiceType::class, ChoiceType::class])] + #[TestWith([ProcessContextType::class, ProcessContextType::class])] + #[TestWith(['\\'.TextType::class, TextType::class])] + public function testFormTypeClass(string $type, string $class): void + { + [$options] = $this->resolveUi(['form' => ['input' => ['type' => $type], 'context' => ['foo' => ['type' => $type]]]]); + + self::assertSame($class, $options['form']['input']['type']); + self::assertSame($class, $options['form']['context']['foo']['type']); + } + + #[DataProvider('provideInvalidFormTypes')] + public function testInvalidFormType(string $type, string $message): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($message); + $this->resolveUi(['form' => ['input' => ['type' => $type]]]); + } + + /** + * @return iterable + */ + public static function provideInvalidFormTypes(): iterable + { + $aliases = implode(', ', array_map(static fn (string $alias): string => \sprintf('"%s"', $alias), array_keys(self::FORM_TYPE_ALIASES))); + $unknown = static fn (string $type): string => \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is neither a form type alias (%s) nor an existing class.', $type, $aliases); + + yield 'unknown alias' => ['foo', $unknown('foo')]; + yield 'missing class' => ['App\Missing', $unknown('App\Missing')]; + yield 'not a form type' => [\stdClass::class, 'The type "stdClass" of the launch form field "form.input" of the process "test.process" must implement "Symfony\Component\Form\FormTypeInterface".']; + yield 'button alias' => ['submit', $unknown('submit')]; + yield 'button class' => [SubmitType::class, \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is a button type, it holds no data.', SubmitType::class)]; + } + + public function testFieldConstraints(): void + { + $constraints = [['NotBlank' => null], ['Length' => ['max' => 10]]]; + [$options] = $this->resolveUi(['form' => [ + 'input' => ['options' => ['constraints' => $constraints]], + 'context' => ['foo' => ['options' => ['constraints' => $constraints]]], + ]]); + + foreach ([$options['form']['input'], $options['form']['context']['foo']] as $field) { + $fieldConstraints = $field['options']['constraints']; + self::assertIsArray($fieldConstraints); + self::assertCount(2, $fieldConstraints); + self::assertInstanceOf(NotBlank::class, $fieldConstraints[0]); + self::assertInstanceOf(Length::class, $fieldConstraints[1]); + self::assertSame(10, $fieldConstraints[1]->max); + } + } + + public function testFieldConstraintsMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "constraints" form option of the launch form field "form.context.foo" of the process "test.process" must be an array of constraints.'); + $this->resolveUi(['form' => ['context' => ['foo' => ['options' => ['constraints' => 'NotBlank']]]]]); + } + + #[TestWith(['data'])] + #[TestWith(['mapped'])] + #[TestWith(['property_path'])] + #[TestWith(['inherit_data'])] + public function testUnsupportedFieldOption(string $option): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The "%s" form option of the launch form field "form.input" of the process "test.process" is not supported, use "default" for the initial value.', $option)); + $this->resolveUi(['form' => ['input' => ['options' => [$option => 'foo']]]]); + } + + public function testContextFieldWithoutSpec(): void + { + [$options] = $this->resolveUi(['form' => ['context' => ['comment' => null]]]); + + self::assertSame(['comment' => ['type' => TextType::class, 'options' => [], 'default' => null]], $options['form']['context']); + } + + public function testContextFieldWithAnUnknownKey(): void + { + $this->expectException(UndefinedOptionsException::class); + $this->expectExceptionMessage('The option "label" does not exist.'); + $this->resolveUi(['form' => ['context' => ['comment' => ['label' => 'Comment']]]]); + } + + public function testContextFieldIsNotAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The launch form field "form.context.comment" of the process "test.process" must be an array or null, "string" given.'); + $this->resolveUi(['form' => ['context' => ['comment' => 'text']]]); + } + + #[TestWith(['my key'])] + #[TestWith([''])] + #[TestWith(['-a'])] + #[TestWith([':a'])] + #[TestWith(['a.b'])] + #[TestWith(['a[b]'])] + public function testInvalidContextKey(int|string $key): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key)); + $this->resolveUi(['form' => ['context' => [$key => null]]]); + } + + /** + * A purely numeric key is cast to an integer by PHP, even quoted in YAML. + */ + #[TestWith([0])] + #[TestWith([123])] + public function testNumericContextKey(int $key): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%d" of "form.context" of the process "test.process" is not supported: purely numeric context keys are cast to integers by PHP.', $key)); + $this->resolveUi(['form' => ['context' => [(string) $key => null]]]); + } + + #[TestWith(['a-b:c'])] + #[TestWith(['_x'])] + #[TestWith(['1a'])] + #[TestWith(['A'])] + public function testValidContextKey(string $key): void + { + [$options] = $this->resolveUi(['form' => ['context' => [$key => null]]]); + + self::assertSame([$key], array_keys($options['form']['context'])); + } + + /** + * The keys are reserved whether the free context is allowed or not: "extra" is the child of the free context + * pairs, "execution_user" is set to the email of the connected user. + */ + #[TestWith(['extra', true])] + #[TestWith(['extra', false])] + #[TestWith(['execution_user', true])] + #[TestWith(['execution_user', false])] + public function testReservedContextKey(string $key, bool $allowExtraContext): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is reserved.', $key)); + $this->resolveUi(['form' => ['allow_extra_context' => $allowExtraContext, 'context' => [$key => null]]]); + } + + /** + * @param array $form + */ + #[DataProvider('provideInvalidFileFields')] + public function testInvalidFileField(array $form, string $message): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($message); + $this->resolveUi(['form' => $form]); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidFileFields(): iterable + { + yield 'file in the context' => [ + ['context' => ['data' => ['type' => 'file']]], + 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', + ]; + yield 'file with a default value' => [ + ['input' => ['type' => 'file', 'default' => 'data.csv']], + 'The launch form field "form.input" of the process "test.process" is a file field, it cannot have a default value.', + ]; + yield 'multiple files' => [ + ['input' => ['type' => FileType::class, 'options' => ['multiple' => true]]], + 'The launch form field "form.input" of the process "test.process" cannot upload multiple files.', + ]; + } + + /** + * @param class-string<\Throwable> $exception + */ + #[DataProvider('provideInvalidForms')] + public function testInvalidForm(mixed $form, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + $this->resolveUi(['form' => $form]); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidForms(): iterable + { + yield 'not an array' => ['foo', InvalidOptionsException::class, 'The option "form" with value "foo" is expected to be of type "array"']; + yield 'unknown key' => [['fields' => []], UndefinedOptionsException::class, 'The option "fields" does not exist.']; + yield 'allow_extra_context not a boolean' => [ + ['allow_extra_context' => 'yes'], + InvalidOptionsException::class, + 'The option "allow_extra_context" with value "yes" is expected to be of type "bool"', + ]; + } + + public function testFormConstraints(): void + { + [$options, $deprecations] = $this->resolveUi(['form' => ['constraints' => [['NotBlank' => null]]]]); + + self::assertSame([], $deprecations); + self::assertCount(1, $options['form']['constraints']); + self::assertInstanceOf(NotBlank::class, $options['form']['constraints'][0]); + } + + public function testDeprecatedConstraintsAreEffective(): void + { + [$options, $deprecations] = $this->resolveUi(['constraints' => [['NotBlank' => null]]]); + + self::assertSame([self::deprecation('constraints', 'form.constraints')], $deprecations); + self::assertCount(1, $options['form']['constraints']); + self::assertInstanceOf(NotBlank::class, $options['form']['constraints'][0]); + } + + public function testConstraintsConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "constraints" and "form.constraints" UI options of the process "test.process" cannot be used together, use "form.constraints" only.'); + $this->resolveUi(['constraints' => [], 'form' => ['constraints' => []]]); + } + + public function testEntrypointTypeConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.'); + $this->resolveUi(['entrypoint_type' => 'text', 'form' => ['input' => ['type' => 'text']]]); + } + + /** + * A null "form.input" is not given: no conflict with "entrypoint_type". + */ + public function testEntrypointTypeWithANullFormInput(): void + { + [$options, $deprecations] = $this->resolveUi(['entrypoint_type' => 'file', 'form' => ['input' => null, 'context' => ['delimiter' => null]]]); + + self::assertSame([self::deprecation('entrypoint_type', 'form.input.type')], $deprecations); + self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $options['form']['input']); + self::assertSame(['delimiter'], array_keys($options['form']['context'])); + } + + /** + * The conflict depends on the presence of the options, not on their values. + */ + public function testNullInputDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); + $this->resolveUi(['form' => ['input' => ['default' => null]], 'default' => ['input' => null]]); + } + + /** + * @param array $ui + * @param array $input + */ + #[DataProvider('provideDeprecatedInputs')] + public function testDeprecatedInputIsFolded(array $ui, array $input): void + { + [$options] = $this->resolveUi($ui); + + self::assertSame($input, $options['form']['input']); + } + + /** + * @return iterable, array}> + */ + public static function provideDeprecatedInputs(): iterable + { + yield 'file entrypoint' => [['entrypoint_type' => 'file'], ['type' => FileType::class, 'options' => [], 'default' => null]]; + yield 'text entrypoint with a default input' => [ + ['entrypoint_type' => 'text', 'default' => ['input' => 'data.csv']], + ['type' => TextType::class, 'options' => [], 'default' => 'data.csv'], + ]; + yield 'file entrypoint with a default input' => [ + ['entrypoint_type' => 'file', 'default' => ['input' => 'data.csv']], + ['type' => FileType::class, 'options' => [], 'default' => null], + ]; + yield 'declared input without default' => [ + ['form' => ['input' => ['type' => 'textarea']], 'default' => ['input' => 'data.csv']], + ['type' => TextareaType::class, 'options' => [], 'default' => 'data.csv'], + ]; + yield 'declared file input without default' => [ + ['form' => ['input' => ['type' => 'file']], 'default' => ['input' => 'data.csv']], + ['type' => FileType::class, 'options' => [], 'default' => null], + ]; + } + + public function testDeprecatedContextDefaultIsFolded(): void + { + [$options] = $this->resolveUi([ + 'form' => ['context' => ['delimiter' => ['type' => 'text'], 'comment' => null]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ','], ['key' => 'delimiter', 'value' => ';']]], + ]); + + self::assertSame(';', $options['form']['context']['delimiter']['default']); + self::assertNull($options['form']['context']['comment']['default']); + } + + public function testContextDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the context key "delimiter" of the process "test.process" is set both in "default.context" and in "form.context.delimiter.default", use "form.context.delimiter.default" only.'); + $this->resolveUi([ + 'form' => ['context' => ['delimiter' => ['default' => null]]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], + ]); + } + + public function testInputDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); + $this->resolveUi(['form' => ['input' => ['default' => 'a.csv']], 'default' => ['input' => 'b.csv']]); + } + + public function testExtraContextKeyNotAllowed(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The context key "foo" of "default.context" of the process "test.process" is not declared in "form.context", and "form.allow_extra_context" is false.'); + $this->resolveUi([ + 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], + 'default' => ['context' => [['key' => 'foo', 'value' => 'bar']]], + ]); + } + + public function testDeclaredContextKeyWithoutExtraContext(): void + { + [$options] = $this->resolveUi([ + 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], + ]); + + self::assertFalse($options['form']['allow_extra_context']); + self::assertSame(',', $options['form']['context']['delimiter']['default']); + } + + /** + * @param array $ui + */ + #[DataProvider('provideDeprecatedOptions')] + public function testDeprecatedOption(array $ui, string $deprecation): void + { + [, $deprecations] = $this->resolveUi($ui); + + self::assertSame([$deprecation], $deprecations); + } + + /** + * @return iterable, string}> + */ + public static function provideDeprecatedOptions(): iterable + { + yield 'entrypoint_type' => [['entrypoint_type' => 'text'], self::deprecation('entrypoint_type', 'form.input.type')]; + yield 'default' => [['default' => []], self::deprecation('default', 'form.input.default" and "form.context..default')]; + yield 'constraints' => [['constraints' => []], self::deprecation('constraints', 'form.constraints')]; + } + + public function testNewFormatIsNotDeprecated(): void + { + [, $deprecations] = $this->resolveUi([ + 'ui_launch_mode' => 'form', + 'form' => [ + 'input' => ['type' => 'file', 'options' => ['constraints' => [['NotNull' => null]]]], + 'context' => ['delimiter' => ['type' => 'choice', 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'allow_extra_context' => false, + 'constraints' => [], + ], + ]); + + self::assertSame([], $deprecations); + } + + public function testLaunchFormDefaultsOfAnUnknownProcess(): void + { + $this->expectException(MissingProcessException::class); + $this->createManager(['test.process' => $this->rawProcess()])->getLaunchFormDefaults('unknown'); + } + + /** + * @param array $ui + * @param array $defaults + */ + #[DataProvider('provideLaunchFormDefaults')] + public function testLaunchFormDefaults(array $ui, array $defaults): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); + + $actual = null; + $deprecations = $this->collectDeprecations(static function () use ($manager, &$actual): void { + $actual = $manager->getLaunchFormDefaults('test.process'); + }); + + self::assertSame($defaults, $actual); + self::assertSame(self::expectedDeprecations($ui), $deprecations); + + // From the UI options already resolved, without resolving them again + [$uiOptions] = $this->resolveUi($ui); + $fromUiOptions = null; + self::assertSame([], $this->collectDeprecations(static function () use ($manager, $uiOptions, &$fromUiOptions): void { + $fromUiOptions = $manager->buildLaunchFormDefaults($uiOptions); + })); + self::assertSame($defaults, $fromUiOptions); + } + + /** + * @return iterable, array}> + */ + public static function provideLaunchFormDefaults(): iterable + { + yield 'new format' => [ + ['form' => [ + 'input' => ['default' => 'data.csv'], + 'context' => ['delimiter' => ['default' => ','], 'comment' => null, 'dry_run' => ['type' => 'checkbox', 'default' => false]], + ]], + ['input' => 'data.csv', 'context' => ['delimiter' => ',', 'comment' => null, 'dry_run' => false]], + ]; + yield 'deprecated format' => [ + ['default' => ['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar']]]], + ['input' => 'data.csv', 'context' => ['foo' => 'bar']], + ]; + yield 'mixed format' => [ + [ + 'form' => ['context' => ['delimiter' => null, 'comment' => ['default' => 'none']]], + 'default' => ['context' => [['key' => 'foo', 'value' => 'bar'], ['key' => 'delimiter', 'value' => ';']]], + ], + ['input' => null, 'context' => ['delimiter' => ';', 'comment' => 'none', 'foo' => 'bar']], + ]; + yield 'file input' => [['form' => ['input' => ['type' => 'file']]], ['input' => null, 'context' => []]]; + // The last row of a key wins, the free keys keep the order of their first row + yield 'duplicated free keys' => [ + ['default' => ['context' => [['key' => 'foo', 'value' => 'a'], ['key' => 'bar', 'value' => 'b'], ['key' => 'foo', 'value' => 'c']]]], + ['input' => null, 'context' => ['foo' => 'c', 'bar' => 'b']], + ]; + // The declared keys first, in their order of declaration, whatever the order of the deprecated rows + yield 'declared keys first' => [ + [ + 'form' => ['context' => ['b' => null, 'a' => ['default' => 'A']]], + 'default' => ['context' => [['key' => 'free', 'value' => 'F'], ['key' => 'b', 'value' => 'B']]], + ], + ['input' => null, 'context' => ['b' => 'B', 'a' => 'A', 'free' => 'F']], + ]; + } + + /** + * The duplicated rows of "default.context" are kept in the resolved "default" option (backward compatibility), + * the last one wins in the effective "form" option. + */ + public function testDuplicatedDefaultContextKeys(): void + { + $rows = [['key' => 'delimiter', 'value' => ','], ['key' => 'foo', 'value' => 'a'], ['key' => 'foo', 'value' => 'b'], ['key' => 'delimiter', 'value' => ';']]; + [$options] = $this->resolveUi(['form' => ['context' => ['delimiter' => null]], 'default' => ['context' => $rows]]); + + self::assertSame($rows, $options['default']['context']); + self::assertSame(';', $options['form']['context']['delimiter']['default']); + } + + public function testDeprecationsAreTriggeredOncePerResolution(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'text']])]); + + $deprecations = $this->collectDeprecations(static function () use ($manager): void { + $manager->getUiOptions('test.process'); + $manager->getUiOptions('test.process'); + }); + + $expected = self::deprecation('entrypoint_type', 'form.input.type'); + self::assertSame([$expected, $expected], $deprecations); + } + + /** + * Each getter resolves only its own option: the deprecations of the UI options are not triggered by the + * notification and logs getters. + */ + public function testNoDeprecationsThroughTheNotificationAndLogsOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['constraints' => []]])]); + + self::assertSame([], $this->collectDeprecations(static fn () => $manager->getNotificationOptions('test.process'))); + self::assertSame([], $this->collectDeprecations(static fn () => $manager->getLogsOptions('test.process'))); + } + + /** + * An invalid launch form does not break the notification of the executions nor the purge of the logs. + */ + public function testInvalidUiOptionsDoNotBreakTheNotificationAndLogsOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess([ + 'ui' => ['form' => ['input' => ['type' => 'App\\Missing']]], + 'notification' => ['enabled' => true], + 'logs' => ['file_max_days' => 30], + ])]); + + self::assertTrue($manager->getNotificationOptions('test.process')['enabled'] ?? null); + self::assertSame(30, $manager->getLogsOptions('test.process')['file_max_days'] ?? null); $this->expectException(InvalidOptionsException::class); $manager->getUiOptions('test.process'); } + /** + * The other options are only checked as arrays when the UI options are resolved. + */ + public function testInvalidNotificationOptionsDoNotBreakTheUiOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess([ + 'ui' => ['source' => 'ERP'], + 'notification' => ['enabled' => 'yes'], + 'logs' => ['file_max_days' => 0], + ])]); + + self::assertSame('ERP', $manager->getUiOptions('test.process')['source'] ?? null); + } + + public function testNewFormatIsNotDeprecatedThroughTheNotificationAndLogsOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => [ + 'ui_launch_mode' => 'form', + 'form' => ['input' => ['type' => 'file'], 'context' => ['delimiter' => ['default' => ',']], 'constraints' => []], + ]])]); + + $notification = null; + $logs = null; + self::assertSame([], $this->collectDeprecations(static function () use ($manager, &$notification, &$logs): void { + $notification = $manager->getNotificationOptions('test.process'); + $logs = $manager->getLogsOptions('test.process'); + })); + self::assertSame(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null], $notification); + self::assertSame(['database_max_days' => null, 'file_max_days' => null], $logs); + } + public function testInvalidLaunchMode(): void { $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'popup']])]); @@ -291,6 +912,75 @@ public function testInvalidLogsOptions(array $options, string $exception): void $manager->getLogsOptions('test.process'); } + /** + * @param array $ui + * + * @return array{0: UiOptions, 1: list} + */ + private function resolveUi(array $ui): array + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); + + $options = null; + $deprecations = $this->collectDeprecations(static function () use ($manager, &$options): void { + $options = $manager->getUiOptions('test.process'); + }); + self::assertIsArray($options); + // Every resolution checks its deprecations: exactly one per deprecated option given + self::assertSame(self::expectedDeprecations($ui), $deprecations); + + return [$options, $deprecations]; + } + + /** + * The deprecations triggered by the resolution of the given UI options, in their order of resolution. + * + * @param array $ui + * + * @return list + */ + private static function expectedDeprecations(array $ui): array + { + $replacements = [ + 'entrypoint_type' => 'form.input.type', + 'constraints' => 'form.constraints', + 'default' => 'form.input.default" and "form.context..default', + ]; + $deprecations = []; + foreach ($replacements as $option => $replacement) { + if (\array_key_exists($option, $ui)) { + $deprecations[] = self::deprecation($option, $replacement); + } + } + + return $deprecations; + } + + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private static function deprecation(string $option, string $replacement): string + { + return \sprintf('Since cleverage/ui-process-bundle 3.3: The "%s" UI option of the process "test.process" is deprecated, use "%s" instead.', $option, $replacement); + } + /** * @param array> $rawConfiguration */ diff --git a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php index 54bd187..14bb537 100644 --- a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php +++ b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php @@ -20,6 +20,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; #[CoversClass(ProcessExtensionRuntime::class)] #[UsesClass(ProcessConfigurationsManager::class)] @@ -35,11 +37,26 @@ public function testGetUiOptions(): void $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); - $options = $runtime->getUiOptions('demo.process'); + $options = []; + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + return true; + }, \E_USER_DEPRECATED); + try { + $options = $runtime->getUiOptions('demo.process'); + } finally { + restore_error_handler(); + } + + self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "demo.process" is deprecated, use "form.input.type" instead.'], $deprecations); // "default" is not compared: its nested resolution depends on the symfony/options-resolver version self::assertArrayHasKey('default', $options); unset($options['default']); + // "form" is the effective launch form, the deprecated options folded into it + self::assertArrayHasKey('form', $options); + unset($options['form']); self::assertEquals([ 'source' => 'Pim', 'target' => null, @@ -50,4 +67,51 @@ public function testGetUiOptions(): void ], $options); self::assertSame([], $runtime->getUiOptions('demo.unknown')); } + + /** + * A process with the "form" option: the effective launch form is returned, without deprecation. + */ + public function testGetUiOptionsWithAForm(): void + { + $configuration = new ProcessConfiguration('demo.process', [], ['ui' => ['ui_launch_mode' => 'form', 'form' => [ + 'input' => ['type' => 'file'], + 'context' => ['delimiter' => ['type' => 'choice', 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'allow_extra_context' => false, + ]]]); + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration')->willReturn(true); + $registry->method('getProcessConfiguration')->willReturn($configuration); + $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); + + $options = []; + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $options = $runtime->getUiOptions('demo.process'); + } finally { + restore_error_handler(); + } + + self::assertSame([], $deprecations); + if ([] === $options) { + self::fail('The UI options of a known process are expected.'); + } + self::assertSame('form', $options['ui_launch_mode']); + self::assertSame( + [ + 'input' => ['type' => FileType::class, 'options' => [], 'default' => null], + 'context' => ['delimiter' => ['type' => ChoiceType::class, 'options' => ['choices' => [',' => ',']], 'default' => ',']], + 'allow_extra_context' => false, + 'constraints' => [], + ], + $options['form'] + ); + // The deprecated options keep their default values + self::assertSame('text', $options['entrypoint_type']); + self::assertSame([], $options['constraints']); + } } From 629709f044bf9c579b592ab328a40267d1d28218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 09:13:50 +0200 Subject: [PATCH 02/17] #160 - execution_user changes must have it's own issue --- src/Controller/Admin/Process/LaunchAction.php | 6 ++++-- src/Message/ProcessExecuteMessage.php | 5 ----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index f40c260..9f16b9b 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -132,8 +132,10 @@ protected function dispatch(string $processCode, mixed $input = null, array $con $message = new ProcessExecuteMessage( $processCode, $input, - // The connected user always wins over a context value - [ProcessExecuteMessage::EXECUTION_USER_KEY => $this->getUser()?->getEmail()] + $context + array_merge( + ['execution_user' => $this->getUser()?->getEmail()], + $context + ) ); $this->messageBus->dispatch($message); } diff --git a/src/Message/ProcessExecuteMessage.php b/src/Message/ProcessExecuteMessage.php index cff8991..2c69dbe 100644 --- a/src/Message/ProcessExecuteMessage.php +++ b/src/Message/ProcessExecuteMessage.php @@ -15,11 +15,6 @@ readonly class ProcessExecuteMessage { - /** - * The context key set to the email of the connected user when a process is launched from the UI. - */ - public const EXECUTION_USER_KEY = 'execution_user'; - /** * @param array $context */ From 92f63be88b29988bc276efcb8fcc7c4b3670d017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 09:38:14 +0200 Subject: [PATCH 03/17] Revert "#160 - execution_user changes must have it's own issue" This reverts commit 588f35e4433b3b2341f19e7b8b02491e516de9d2. --- src/Controller/Admin/Process/LaunchAction.php | 6 ++---- src/Message/ProcessExecuteMessage.php | 5 +++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index 9f16b9b..f40c260 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -132,10 +132,8 @@ protected function dispatch(string $processCode, mixed $input = null, array $con $message = new ProcessExecuteMessage( $processCode, $input, - array_merge( - ['execution_user' => $this->getUser()?->getEmail()], - $context - ) + // The connected user always wins over a context value + [ProcessExecuteMessage::EXECUTION_USER_KEY => $this->getUser()?->getEmail()] + $context ); $this->messageBus->dispatch($message); } diff --git a/src/Message/ProcessExecuteMessage.php b/src/Message/ProcessExecuteMessage.php index 2c69dbe..cff8991 100644 --- a/src/Message/ProcessExecuteMessage.php +++ b/src/Message/ProcessExecuteMessage.php @@ -15,6 +15,11 @@ readonly class ProcessExecuteMessage { + /** + * The context key set to the email of the connected user when a process is launched from the UI. + */ + public const EXECUTION_USER_KEY = 'execution_user'; + /** * @param array $context */ From a5e397abf0607293cadff98d5f028e1a995cd087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 11:55:41 +0200 Subject: [PATCH 04/17] #160 - Revert: execution_user is no longer forced to the connected user nor reserved (out of scope) Out of the scope of #160 (review of #168): back to the behaviour of main. - LaunchAction::dispatch(): the context is merged over execution_user again (array_merge), as before. - Remove ProcessExecuteMessage::EXECUTION_USER_KEY, ProcessContextType::createExecutionUserConstraint() and the key_constraints option of ProcessContextType. - execution_user is no longer reserved in the free context pairs of the launch form, in the schedules, nor in form.context ("extra" stays reserved: it is the child of the free pairs). - Tests and documentation updated accordingly. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- UPGRADE.md | 5 +- docs/reference/02-process_ui_options.md | 9 +-- .../04-process_executions_and_logs.md | 2 +- docs/reference/05-scheduler.md | 2 +- src/Controller/Admin/Process/LaunchAction.php | 6 +- .../Admin/ProcessScheduleCrudController.php | 2 - src/Form/Type/LaunchType.php | 5 +- src/Form/Type/ProcessContextType.php | 21 ------ src/Manager/ProcessConfigurationsManager.php | 5 +- src/Message/ProcessExecuteMessage.php | 5 -- .../Admin/Process/LaunchActionTest.php | 74 ------------------- tests/Form/Type/LaunchTypeTest.php | 14 ---- tests/Form/Type/ProcessContextTypeTest.php | 20 ----- tests/Functional/LaunchActionTest.php | 19 ----- tests/Functional/ProcessScheduleCrudTest.php | 38 +--------- .../ProcessConfigurationsManagerTest.php | 5 +- 17 files changed, 19 insertions(+), 215 deletions(-) delete mode 100644 tests/Controller/Admin/Process/LaunchActionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ce764d4..f50b87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The key of a free context pair (launch form and schedules) must be a valid form field name, not only digits (new `key_constraints` option of `ProcessContextType`). The `execution_user` context value is always the email of the connected user (reserved in the free pairs of the launch form and of the schedules, and in `form.context`; constant `ProcessExecuteMessage::EXECUTION_USER_KEY`, constraint `ProcessContextType::createExecutionUserConstraint()`). Existing schedules with a key that is no longer valid keep running, but must be renamed to be saved from the UI. `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The key of a free context pair (launch form and schedules) must be a valid form field name, not only digits. Existing schedules with a key that is no longer valid keep running, but must be renamed to be saved from the UI. `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index e7666b9..ac95f15 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -93,10 +93,7 @@ options: - The key of a free context pair (launch form and schedules) must contain letters, digits, `_`, `-` and `:`, start with a letter, a digit or `_`, and not be only digits, like a declared context key. -- The `execution_user` context value is always the email of the connected user when a process is launched from the - UI: a free pair `execution_user` is refused in the launch form and in the schedules, and `execution_user` cannot be - declared in `form.context`. -- Existing schedules whose context contains a key that is no longer valid (or `execution_user`) keep running unchanged: +- Existing schedules whose context contains a key that is no longer valid keep running unchanged: the cron does not validate their context. But they cannot be saved from the UI until the key is renamed (or the pair removed), even to change another field: rename it, and update the process accordingly (e.g. its `{{ key }}` contextualized options), as renaming the key changes the context received by the process. Enabling or disabling a diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 626b59d..c044d71 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -80,7 +80,7 @@ Launch modes In every mode, the process is not executed by the web request: a message is dispatched to the `execute_process` Messenger transport and a worker runs it (see [messenger](08-messenger.md)). The UI adds the email of the connected -user to the process context, under the `execution_user` key: this value always wins, the launch form cannot set it. +user to the process context, under the `execution_user` key. ### Launch form @@ -151,7 +151,7 @@ A field spec (`form.input`, `form.context.`) accepts exactly three keys: The context keys must be valid form field names (letters, digits, `_`, `-` and `:`, starting with a letter, a digit or `_`). A purely numeric key (e.g. `123`, even quoted in YAML) is not supported: PHP casts it to an integer. The -`extra` key (the free context pairs) and the `execution_user` key (the email of the connected user) are reserved. +`extra` key (the free context pairs) is reserved. The type aliases are the block prefixes of the Symfony core form types, and `entity`: @@ -208,9 +208,8 @@ added. The key of a free pair follows the rule of the declared context keys: letters, digits, `_`, `-` and `:`, starting with a letter, a digit or `_`, and not only digits. Otherwise, it is reported as a validation error on its key (`The -context key "a b" is not valid: ...`), and the process is not launched. The same applies to a free pair whose key is: -- a declared field (`The context key "delimiter" is a field of the launch form, use this field instead.`); -- `execution_user` (`The context key "execution_user" is reserved: it is set to the email of the connected user.`). +context key "a b" is not valid: ...`), and the process is not launched. The same applies to a free pair whose key is +a declared field (`The context key "delimiter" is a field of the launch form, use this field instead.`). The key format rule also applies to the context pairs of the [schedules](05-scheduler.md). diff --git a/docs/reference/04-process_executions_and_logs.md b/docs/reference/04-process_executions_and_logs.md index d1e03e7..3006440 100644 --- a/docs/reference/04-process_executions_and_logs.md +++ b/docs/reference/04-process_executions_and_logs.md @@ -31,7 +31,7 @@ Executions are stored in the `process_execution` table (`CleverAge\UiProcessBund | `heartbeatAt` | Last sign of life of the running process, see [not responding executions](#not-responding-executions). | | `logFilename` | Name of the log file. | | `report` | Associative array, see [report](#report). | -| `context` | Process context (including `execution_user` when launched from the UI, reserved in the launch form and the schedules). | +| `context` | Process context (including `execution_user` when launched from the UI). | ### Executions list diff --git a/docs/reference/05-scheduler.md b/docs/reference/05-scheduler.md index 89d26a7..d5a12f4 100644 --- a/docs/reference/05-scheduler.md +++ b/docs/reference/05-scheduler.md @@ -15,7 +15,7 @@ Schedules are stored in the `process_schedule` table (`CleverAge\UiProcessBundle | `type` | `cron` or `every`. | | `expression` | `cron`: a cron expression, e.g. `*/5 * * * *` or `@daily` (see [cron expression triggers](https://symfony.com/doc/current/scheduler.html#cron-expression-triggers)). `every`: a relative time, e.g. `5 seconds`, `1 hour`, `1 day` (see [periodical triggers](https://symfony.com/doc/current/scheduler.html#periodical-triggers)); it must be parsable by `strtotime()`. | | `input` | Optional process input (string, 255 characters max), e.g. a file path. | -| `context` | Optional list of key/value pairs, passed as process context. A key contains letters, digits, `_`, `-` and `:`, starts with a letter, a digit or `_`, and is not only digits. The `execution_user` key is reserved (set to the email of the connected user when a process is launched from the UI). | +| `context` | Optional list of key/value pairs, passed as process context. A key contains letters, digits, `_`, `-` and `:`, starts with a letter, a digit or `_`, and is not only digits. | | `enabled` | A disabled schedule is not run (default: enabled). It can be switched directly from the schedules list. | The schedules list displays the next execution date of the enabled `cron` schedules. diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index f40c260..9f16b9b 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -132,8 +132,10 @@ protected function dispatch(string $processCode, mixed $input = null, array $con $message = new ProcessExecuteMessage( $processCode, $input, - // The connected user always wins over a context value - [ProcessExecuteMessage::EXECUTION_USER_KEY => $this->getUser()?->getEmail()] + $context + array_merge( + ['execution_user' => $this->getUser()?->getEmail()], + $context + ) ); $this->messageBus->dispatch($message); } diff --git a/src/Controller/Admin/ProcessScheduleCrudController.php b/src/Controller/Admin/ProcessScheduleCrudController.php index 2e53a62..46212de 100644 --- a/src/Controller/Admin/ProcessScheduleCrudController.php +++ b/src/Controller/Admin/ProcessScheduleCrudController.php @@ -108,8 +108,6 @@ public function configureFields(string $pageName): iterable ->setFormTypeOption('entry_type', ProcessContextType::class) ->hideOnIndex() ->setFormTypeOption('entry_options.label', 'Context (key/value)') - // As in the launch form: set to the email of the connected user when a process is launched from the UI - ->setFormTypeOption('entry_options.key_constraints', [ProcessContextType::createExecutionUserConstraint()]) ->setFormTypeOption('label', '') ->setFormTypeOption('required', false), ]; diff --git a/src/Form/Type/LaunchType.php b/src/Form/Type/LaunchType.php index 1604bcc..b29e7ab 100644 --- a/src/Form/Type/LaunchType.php +++ b/src/Form/Type/LaunchType.php @@ -68,10 +68,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void CollectionType::class, [ 'entry_type' => ProcessContextType::class, - 'entry_options' => [ - 'reserved_keys' => array_keys($form['context']), - 'key_constraints' => [ProcessContextType::createExecutionUserConstraint()], - ], + 'entry_options' => ['reserved_keys' => array_keys($form['context'])], 'allow_add' => true, 'allow_delete' => true, 'required' => false, diff --git a/src/Form/Type/ProcessContextType.php b/src/Form/Type/ProcessContextType.php index 4eac854..9437b55 100644 --- a/src/Form/Type/ProcessContextType.php +++ b/src/Form/Type/ProcessContextType.php @@ -13,11 +13,9 @@ namespace CleverAge\UiProcessBundle\Form\Type; -use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Regex; @@ -46,9 +44,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void match: false ); } - /** @var Constraint[] $additionalKeyConstraints */ - $additionalKeyConstraints = $options['key_constraints']; - $keyConstraints = [...$keyConstraints, ...$additionalKeyConstraints]; $builder->add( 'key', null, @@ -68,25 +63,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ); } - /** - * The constraint of a context key reserving "execution_user", set to the email of the connected user when a process - * is launched from the UI. - */ - public static function createExecutionUserConstraint(): Choice - { - return new Choice( - choices: [ProcessExecuteMessage::EXECUTION_USER_KEY], - message: 'The context key {{ value }} is reserved: it is set to the email of the connected user.', - match: false - ); - } - public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefault('reserved_keys', []); $resolver->setAllowedTypes('reserved_keys', 'string[]'); - // Additional constraints of the key - $resolver->setDefault('key_constraints', []); - $resolver->setAllowedTypes('key_constraints', Constraint::class.'[]'); } } diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index 2c6c97a..a0ba05e 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -17,7 +17,6 @@ use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; -use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\ButtonTypeInterface; @@ -407,8 +406,8 @@ private static function resolveForm(array $form, string $processCode): array if ('' === $key || !FormConfigBuilder::isValidName($key)) { throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key, $processCode)); } - // "extra" is the child of the free context pairs, "execution_user" is set to the email of the connected user - if (\in_array($key, ['extra', ProcessExecuteMessage::EXECUTION_USER_KEY], true)) { + // "extra" is the child of the free context pairs + if ('extra' === $key) { throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is reserved.', $key, $processCode)); } if (null !== $spec && !\is_array($spec)) { diff --git a/src/Message/ProcessExecuteMessage.php b/src/Message/ProcessExecuteMessage.php index cff8991..2c69dbe 100644 --- a/src/Message/ProcessExecuteMessage.php +++ b/src/Message/ProcessExecuteMessage.php @@ -15,11 +15,6 @@ readonly class ProcessExecuteMessage { - /** - * The context key set to the email of the connected user when a process is launched from the UI. - */ - public const EXECUTION_USER_KEY = 'execution_user'; - /** * @param array $context */ diff --git a/tests/Controller/Admin/Process/LaunchActionTest.php b/tests/Controller/Admin/Process/LaunchActionTest.php deleted file mode 100644 index 7b918c8..0000000 --- a/tests/Controller/Admin/Process/LaunchActionTest.php +++ /dev/null @@ -1,74 +0,0 @@ - */ - public array $messages = []; - - public function dispatch(object $message, array $stamps = []): Envelope - { - $this->messages[] = $message; - - return new Envelope($message, $stamps); - } - }; - $requestStack = new RequestStack(); - $action = new class($bus, $requestStack, new ProcessConfigurationsManager(new ProcessConfigurationRegistry([], 'stop')), new AdminContextProvider($requestStack)) extends LaunchAction { - /** - * @param array $context - */ - public function launch(string $processCode, mixed $input, array $context): void - { - $this->dispatch($processCode, $input, $context); - } - - protected function getUser(): User - { - return (new User())->setEmail('user@example.com'); - } - }; - - $action->launch('demo.process', 'data.csv', ['foo' => 'bar', 'execution_user' => 'someone@example.com']); - - self::assertCount(1, $bus->messages); - $message = $bus->messages[0]; - self::assertInstanceOf(ProcessExecuteMessage::class, $message); - self::assertSame(['execution_user' => 'user@example.com', 'foo' => 'bar'], $message->context); - } -} diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php index e673eea..43b0421 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -18,7 +18,6 @@ use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -216,19 +215,6 @@ public function testExtraContextRowWithADeclaredKey(): void self::assertCount(0, $form->get('context')->get('delimiter')->getErrors()); } - #[TestWith(['text.process'])] - #[TestWith(['fields.process'])] - public function testExtraContextRowWithTheExecutionUserKey(string $processCode): void - { - $form = $this->factory->create(LaunchType::class, null, ['process_code' => $processCode]); - $form->submit(['context' => ['extra' => [['key' => 'execution_user', 'value' => 'someone@example.com']]]]); - - self::assertFalse($form->isValid()); - $errors = $form->get('context')->get('extra')->get('0')->get('key')->getErrors(); - self::assertCount(1, $errors); - self::assertSame('The context key "execution_user" is reserved: it is set to the email of the connected user.', $errors[0]->getMessage()); - } - public function testFieldConstraint(): void { $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); diff --git a/tests/Form/Type/ProcessContextTypeTest.php b/tests/Form/Type/ProcessContextTypeTest.php index cefb1ff..2e70e8a 100644 --- a/tests/Form/Type/ProcessContextTypeTest.php +++ b/tests/Form/Type/ProcessContextTypeTest.php @@ -21,7 +21,6 @@ use Symfony\Component\Form\FormExtensionInterface; use Symfony\Component\Form\Test\TypeTestCase; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; -use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Validation; @@ -161,23 +160,4 @@ public function testEmptyKey(): void // Reported by the NotBlank constraint only self::assertCount(1, $form->get('key')->getErrors()); } - - public function testKeyConstraints(): void - { - $form = $this->factory->create(ProcessContextType::class, null, [ - 'key_constraints' => [new Choice(choices: ['execution_user'], message: 'Reserved key {{ value }}.', match: false)], - ]); - $form->submit(['key' => 'execution_user', 'value' => 'bar']); - - self::assertFalse($form->isValid()); - $errors = $form->get('key')->getErrors(); - self::assertCount(1, $errors); - self::assertSame('Reserved key "execution_user".', $errors[0]->getMessage()); - } - - public function testKeyConstraintsMustBeConstraints(): void - { - $this->expectException(InvalidOptionsException::class); - $this->factory->create(ProcessContextType::class, null, ['key_constraints' => ['NotBlank']]); - } } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 4253b9c..a67012e 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -289,25 +289,6 @@ public function testExtraContextRowWithADeclaredKey(): void self::assertSame([], $this->getDispatchedMessages()); } - public function testExtraContextRowWithTheExecutionUserKey(): void - { - $this->login(); - - $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form'); - $form = $crawler->selectButton('Launch')->form(); - /** @var array> $values */ - $values = $form->getPhpValues(); - $values['launch']['context'] = ['extra' => [['key' => 'execution_user', 'value' => 'someone@example.com']]]; - $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); - - self::assertResponseIsSuccessful(); - self::assertSame( - 'The context key "execution_user" is reserved: it is set to the email of the connected user.', - $this->getFieldError($crawler, 'launch_context_extra_0_key') - ); - self::assertSame([], $this->getDispatchedMessages()); - } - public function testDateFields(): void { $this->login(); diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index ba829dd..d363a0f 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -42,13 +42,10 @@ use CleverAge\UiProcessBundle\Validator\CronExpressionValidator; use CleverAge\UiProcessBundle\Validator\EveryExpressionValidator; use CleverAge\UiProcessBundle\Validator\IsValidProcessCodeValidator; -use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; -use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\DomCrawler\Crawler; -use Symfony\Component\Form\FormFactoryInterface; #[CoversClass(ProcessScheduleCrudController::class)] #[UsesClass(EnumField::class)] @@ -187,39 +184,10 @@ public function testDisable(): void self::assertDoesNotMatchRegularExpression('/\d{4}-\d{2}-\d{2}T04:00:00/', $crawler->filter('table')->text()); } - /** - * The "execution_user" context key is reserved in the schedules too, as in the launch form. - */ - public function testExecutionUserIsAReservedContextKey(): void - { - $controller = static::getContainer()->get(ProcessScheduleCrudController::class); - self::assertInstanceOf(ProcessScheduleCrudController::class, $controller); - $entryOptions = null; - foreach ($controller->configureFields(Crud::PAGE_EDIT) as $field) { - if ($field instanceof FieldInterface && 'context' === $field->getAsDto()->getProperty()) { - $entryOptions = $field->getAsDto()->getFormTypeOptions()['entry_options'] ?? null; - } - } - self::assertIsArray($entryOptions); - /** @var array $rowOptions */ - $rowOptions = $entryOptions + ['csrf_protection' => false]; - - $formFactory = static::getContainer()->get('form.factory'); - self::assertInstanceOf(FormFactoryInterface::class, $formFactory); - $row = $formFactory->create(ProcessContextType::class, null, $rowOptions); - $row->submit(['key' => 'execution_user', 'value' => 'someone@example.com']); - - self::assertFalse($row->isValid()); - $errors = $row->get('key')->getErrors(); - self::assertCount(1, $errors); - self::assertSame('The context key "execution_user" is reserved: it is set to the email of the connected user.', $errors[0]->getMessage()); - } - /** * The keys of the context rows follow the same rules as in the launch form. */ #[TestWith(['a b', 'The context key "a b" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.'])] - #[TestWith(['execution_user', 'The context key "execution_user" is reserved: it is set to the email of the connected user.'])] public function testCreateWithAnInvalidContextKey(string $key, string $error): void { $this->login(); @@ -269,7 +237,7 @@ public function testScheduleWithAContextKeyNowInvalid(): void { $this->login(); $schedule = $this->createSchedule(ProcessScheduleType::CRON, '0 4 * * *'); - $schedule->setContext([['key' => 'execution_user', 'value' => 'someone@example.com']]); + $schedule->setContext([['key' => 'a b', 'value' => 'someone@example.com']]); $this->getEntityManager()->flush(); $id = $schedule->getId(); @@ -284,7 +252,7 @@ public function testScheduleWithAContextKeyNowInvalid(): void $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); self::assertNotNull($schedule); self::assertFalse($schedule->isEnabled()); - self::assertSame([['key' => 'execution_user', 'value' => 'someone@example.com']], $schedule->getContext()); + self::assertSame([['key' => 'a b', 'value' => 'someone@example.com']], $schedule->getContext()); // Saved unchanged: refused $crawler = $this->client->request('GET', '/process/process-schedule/'.$id.'/edit'); @@ -296,7 +264,7 @@ public function testScheduleWithAContextKeyNowInvalid(): void $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); self::assertResponseStatusCodeSame(422); self::assertSame( - ['The context key "execution_user" is reserved: it is set to the email of the connected user.'], + ['The context key "a b" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.'], $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key') ); $this->getEntityManager()->clear(); diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 269b868..bbe9c0c 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -385,13 +385,10 @@ public function testValidContextKey(string $key): void } /** - * The keys are reserved whether the free context is allowed or not: "extra" is the child of the free context - * pairs, "execution_user" is set to the email of the connected user. + * The key is reserved whether the free context is allowed or not: "extra" is the child of the free context pairs. */ #[TestWith(['extra', true])] #[TestWith(['extra', false])] - #[TestWith(['execution_user', true])] - #[TestWith(['execution_user', false])] public function testReservedContextKey(string $key, bool $allowExtraContext): void { $this->expectException(InvalidOptionsException::class); From eba750522272d30186cce0a686a7c24a1ea9d102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 11:57:13 +0200 Subject: [PATCH 05/17] #160 - Revert: no format rule on the keys of the free context pairs and of the schedules (out of scope) Out of the scope of #160 and a BC break in a minor (review of #168): keys such as "api.token", "a/b" or "a b" are accepted again in the free context pairs of the launch form and in the schedules, as on main. - ProcessContextType: remove the Regex constraint of the key (NotBlank and the reserved_keys option of #160 are kept). - The naming rule still applies to the keys declared in form.context, which are form field names. - ProcessScheduleCrudTest and docs/reference/05-scheduler.md back to main; tests, reference documentation, upgrade guide and changelog updated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- UPGRADE.md | 13 --- docs/reference/02-process_ui_options.md | 8 +- docs/reference/05-scheduler.md | 2 +- src/Form/Type/ProcessContextType.php | 11 +- tests/Form/Type/ProcessContextTypeTest.php | 38 +------ tests/Functional/ProcessScheduleCrudTest.php | 112 ------------------- 7 files changed, 6 insertions(+), 180 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f50b87a..0c6d1e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The key of a free context pair (launch form and schedules) must be a valid form field name, not only digits. Existing schedules with a key that is no longer valid keep running, but must be renamed to be saved from the UI. `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index ac95f15..ed50422 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -89,19 +89,6 @@ options: - A process using a deprecated option triggers its deprecations each time its UI options are resolved (process list, launch form, executions list): expect them in the deprecation logs until the process is migrated. -### Context keys of the launch form and the schedules - -- The key of a free context pair (launch form and schedules) must contain letters, digits, `_`, `-` and `:`, start with - a letter, a digit or `_`, and not be only digits, like a declared context key. -- Existing schedules whose context contains a key that is no longer valid keep running unchanged: - the cron does not validate their context. But they cannot be saved from the UI until the key is renamed (or the - pair removed), even to change another field: rename it, and update the process accordingly (e.g. its `{{ key }}` - contextualized options), as renaming the key changes the context received by the process. Enabling or disabling a - schedule from the list is not affected (no form). -- The same applies to the deprecated `default.context` option: a pair whose key is not valid pre-fills a row that - prevents the launch form from being submitted, until the user changes it; the process configuration is not - reported as invalid. Rename the key (or move it to `form.context`). - ### Invalid notification and logs options `ProcessConfigurationsManager` now resolves only the requested option: invalid `notification` or `logs` options of a diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index c044d71..59d3d76 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -206,12 +206,8 @@ With `allow_extra_context: true`, the free key/value pairs are displayed after t (key/value)" label (no label when no context key is declared). With `allow_extra_context: false`, no free pair can be added. -The key of a free pair follows the rule of the declared context keys: letters, digits, `_`, `-` and `:`, starting with -a letter, a digit or `_`, and not only digits. Otherwise, it is reported as a validation error on its key (`The -context key "a b" is not valid: ...`), and the process is not launched. The same applies to a free pair whose key is -a declared field (`The context key "delimiter" is a field of the launch form, use this field instead.`). - -The key format rule also applies to the context pairs of the [schedules](05-scheduler.md). +A free pair whose key is a declared field is reported as a validation error on its key (`The context key "delimiter" +is a field of the launch form, use this field instead.`), and the process is not launched. > **Free pairs and placeholders.** A free pair gives a value to any context key, so to any `{{ key }}` placeholder of > the options of the process tasks, not only to the keys declared in `form.context`. As soon as a process declares its diff --git a/docs/reference/05-scheduler.md b/docs/reference/05-scheduler.md index d5a12f4..a2481e7 100644 --- a/docs/reference/05-scheduler.md +++ b/docs/reference/05-scheduler.md @@ -15,7 +15,7 @@ Schedules are stored in the `process_schedule` table (`CleverAge\UiProcessBundle | `type` | `cron` or `every`. | | `expression` | `cron`: a cron expression, e.g. `*/5 * * * *` or `@daily` (see [cron expression triggers](https://symfony.com/doc/current/scheduler.html#cron-expression-triggers)). `every`: a relative time, e.g. `5 seconds`, `1 hour`, `1 day` (see [periodical triggers](https://symfony.com/doc/current/scheduler.html#periodical-triggers)); it must be parsable by `strtotime()`. | | `input` | Optional process input (string, 255 characters max), e.g. a file path. | -| `context` | Optional list of key/value pairs, passed as process context. A key contains letters, digits, `_`, `-` and `:`, starts with a letter, a digit or `_`, and is not only digits. | +| `context` | Optional list of key/value pairs, passed as process context. | | `enabled` | A disabled schedule is not run (default: enabled). It can be switched directly from the schedules list. | The schedules list displays the next execution date of the enabled `cron` schedules. diff --git a/src/Form/Type/ProcessContextType.php b/src/Form/Type/ProcessContextType.php index 9437b55..168f460 100644 --- a/src/Form/Type/ProcessContextType.php +++ b/src/Form/Type/ProcessContextType.php @@ -18,22 +18,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; -use Symfony\Component\Validator\Constraints\Regex; /** @template-extends AbstractType */ class ProcessContextType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $keyConstraints = [ - new NotBlank(), - // The rule of FormConfigBuilder::isValidName() applied to the declared context keys of the launch form, and - // not only digits (PHP casts such an array key to an integer) - new Regex( - pattern: '/^(?![0-9]+$)[a-zA-Z0-9_][a-zA-Z0-9_\-:]*$/D', - message: 'The context key {{ value }} is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.' - ), - ]; + $keyConstraints = [new NotBlank()]; /** @var string[] $reservedKeys */ $reservedKeys = $options['reserved_keys']; if ([] !== $reservedKeys) { diff --git a/tests/Form/Type/ProcessContextTypeTest.php b/tests/Form/Type/ProcessContextTypeTest.php index 2e70e8a..8140c79 100644 --- a/tests/Form/Type/ProcessContextTypeTest.php +++ b/tests/Form/Type/ProcessContextTypeTest.php @@ -54,8 +54,7 @@ public function testFields(): void self::assertIsArray($constraints); self::assertInstanceOf(NotBlank::class, $constraints[0]); } - // The format of the key is checked - self::assertCount(2, (array) $form->get('key')->getConfig()->getOption('constraints')); + self::assertCount(1, (array) $form->get('key')->getConfig()->getOption('constraints')); self::assertCount(1, (array) $form->get('value')->getConfig()->getOption('constraints')); } @@ -117,47 +116,12 @@ public function testReservedKeysMustBeAnArray(): void $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => 'delimiter']); } - #[TestWith(['foo'])] - #[TestWith(['Delimiter'])] - #[TestWith(['a-b:c'])] - #[TestWith(['_x'])] - #[TestWith(['1a'])] - #[TestWith(['dry_run'])] - public function testValidKeyFormat(string $key): void - { - $form = $this->factory->create(ProcessContextType::class); - $form->submit(['key' => $key, 'value' => 'bar']); - - self::assertTrue($form->isValid()); - } - - #[TestWith(['zz)|x|(?:zz'])] - #[TestWith(['a/b'])] - #[TestWith(['a b'])] - #[TestWith(['{{x}}'])] - #[TestWith(['-a'])] - #[TestWith(['123'])] - public function testInvalidKeyFormat(string $key): void - { - $form = $this->factory->create(ProcessContextType::class); - $form->submit(['key' => $key, 'value' => 'bar']); - - self::assertFalse($form->isValid()); - $errors = $form->get('key')->getErrors(); - self::assertCount(1, $errors); - self::assertSame( - \sprintf('The context key "%s" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.', $key), - $errors[0]->getMessage() - ); - } - public function testEmptyKey(): void { $form = $this->factory->create(ProcessContextType::class); $form->submit(['key' => '', 'value' => 'bar']); self::assertFalse($form->isValid()); - // Reported by the NotBlank constraint only self::assertCount(1, $form->get('key')->getErrors()); } } diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index d363a0f..f723f44 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -43,9 +43,7 @@ use CleverAge\UiProcessBundle\Validator\EveryExpressionValidator; use CleverAge\UiProcessBundle\Validator\IsValidProcessCodeValidator; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; -use Symfony\Component\DomCrawler\Crawler; #[CoversClass(ProcessScheduleCrudController::class)] #[UsesClass(EnumField::class)] @@ -184,103 +182,6 @@ public function testDisable(): void self::assertDoesNotMatchRegularExpression('/\d{4}-\d{2}-\d{2}T04:00:00/', $crawler->filter('table')->text()); } - /** - * The keys of the context rows follow the same rules as in the launch form. - */ - #[TestWith(['a b', 'The context key "a b" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.'])] - public function testCreateWithAnInvalidContextKey(string $key, string $error): void - { - $this->login(); - - $crawler = $this->client->request('GET', '/process/process-schedule/new'); - $form = $crawler->selectButton('Create')->form(); - /** @var array> $values */ - $values = $form->getPhpValues(); - $values['ProcessSchedule']['process'] = 'test.process'; - $values['ProcessSchedule']['type'] = 'cron'; - $values['ProcessSchedule']['expression'] = '*/5 * * * *'; - $values['ProcessSchedule']['context'] = [['key' => 'delimiter', 'value' => ';'], ['key' => $key, 'value' => 'value']]; - $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); - - self::assertResponseStatusCodeSame(422); - self::assertSame([$error], $this->getFieldErrors($crawler, 'ProcessSchedule_context_1_key')); - self::assertSame([], $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key')); - self::assertSame([], $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll()); - } - - public function testCreateWithAValidContextKey(): void - { - $this->login(); - - $crawler = $this->client->request('GET', '/process/process-schedule/new'); - $form = $crawler->selectButton('Create')->form(); - /** @var array> $values */ - $values = $form->getPhpValues(); - $values['ProcessSchedule']['process'] = 'test.process'; - $values['ProcessSchedule']['type'] = 'cron'; - $values['ProcessSchedule']['expression'] = '*/5 * * * *'; - $values['ProcessSchedule']['context'] = [['key' => 'delimiter', 'value' => ';']]; - $this->client->request($form->getMethod(), $form->getUri(), $values); - - self::assertResponseRedirects(); - $this->getEntityManager()->clear(); - $schedules = $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll(); - self::assertCount(1, $schedules); - self::assertSame([['key' => 'delimiter', 'value' => ';']], $schedules[0]->getContext()); - } - - /** - * A schedule saved before the rules on the context keys: it is saved again only once its key is fixed, and its - * "enabled" toggle of the index page is not affected. - */ - public function testScheduleWithAContextKeyNowInvalid(): void - { - $this->login(); - $schedule = $this->createSchedule(ProcessScheduleType::CRON, '0 4 * * *'); - $schedule->setContext([['key' => 'a b', 'value' => 'someone@example.com']]); - $this->getEntityManager()->flush(); - $id = $schedule->getId(); - - // The toggle of the index page (an AJAX PATCH request): no form validation - $crawler = $this->client->request('GET', '/process/process-schedule'); - self::assertResponseIsSuccessful(); - $toggleUrl = (string) $crawler->filter('[data-toggle-url]')->attr('data-toggle-url'); - self::assertStringContainsString('fieldName=enabled', $toggleUrl); - $this->client->xmlHttpRequest('PATCH', $toggleUrl.'&newValue=false'); - self::assertResponseIsSuccessful(); - $this->getEntityManager()->clear(); - $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); - self::assertNotNull($schedule); - self::assertFalse($schedule->isEnabled()); - self::assertSame([['key' => 'a b', 'value' => 'someone@example.com']], $schedule->getContext()); - - // Saved unchanged: refused - $crawler = $this->client->request('GET', '/process/process-schedule/'.$id.'/edit'); - self::assertResponseIsSuccessful(); - $form = $crawler->selectButton('Save changes')->form(); - /** @var array> $values */ - $values = $form->getPhpValues(); - $values['ProcessSchedule']['expression'] = '0 5 * * *'; - $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); - self::assertResponseStatusCodeSame(422); - self::assertSame( - ['The context key "a b" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.'], - $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key') - ); - $this->getEntityManager()->clear(); - self::assertSame('0 4 * * *', $this->getEntityManager()->find(ProcessSchedule::class, $id)?->getExpression()); - - // Saved once the key is fixed - $values['ProcessSchedule']['context'] = [['key' => 'user', 'value' => 'someone@example.com']]; - $this->client->request($form->getMethod(), $form->getUri(), $values); - self::assertResponseRedirects(); - $this->getEntityManager()->clear(); - $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); - self::assertNotNull($schedule); - self::assertSame('0 5 * * *', $schedule->getExpression()); - self::assertSame([['key' => 'user', 'value' => 'someone@example.com']], $schedule->getContext()); - } - public function testEntityFqcn(): void { self::assertSame(ProcessSchedule::class, ProcessScheduleCrudController::getEntityFqcn()); @@ -297,17 +198,4 @@ private function createSchedule(ProcessScheduleType $type, string $expression): return $schedule; } - - /** - * The error messages displayed under the form field with the given id. - * - * @return list - */ - private function getFieldErrors(Crawler $crawler, string $id): array - { - $widget = $crawler->filter('#'.$id)->closest('.form-widget'); - self::assertNotNull($widget, \sprintf('No field "%s".', $id)); - - return $widget->filter('.invalid-feedback')->each(static fn (Crawler $error): string => trim($error->text())); - } } From f29986754e8afe345dc81be9dea196bd80e55a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 11:59:18 +0200 Subject: [PATCH 06/17] #160 - Revert: ProcessConfigurationsManager resolves the ui, notification and logs options together again (out of scope) Out of the scope of #160 (review of #168): as on main, the getters resolve the ui, notification and logs options in one pass, so invalid notification or logs options are reported again by the process list. - resolveOptions() resolves the three options again (notification and logs normalizers inlined as on main, the ui one delegating to the resolution of the launch form). - Remove the tests of the independent resolutions. - docs/reference/04-process_executions_and_logs.md and 09-notifications.md back to main; upgrade guide and changelog updated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- UPGRADE.md | 7 - .../04-process_executions_and_logs.md | 3 - docs/reference/09-notifications.md | 4 +- src/Manager/ProcessConfigurationsManager.php | 146 +++++++----------- .../ProcessConfigurationsManagerTest.php | 61 -------- 6 files changed, 60 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6d1e0..3ddfe6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index ed50422..0900856 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -89,13 +89,6 @@ options: - A process using a deprecated option triggers its deprecations each time its UI options are resolved (process list, launch form, executions list): expect them in the deprecation logs until the process is migrated. -### Invalid notification and logs options - -`ProcessConfigurationsManager` now resolves only the requested option: invalid `notification` or `logs` options of a -process are no longer reported by the process list. They are only reported when they are used: at the end of an -execution, by the notifier in the Messenger worker (no notification is sent), and by the -`cleverage:ui-process:purge-logs` command. - ## v3.0 ### Import routes diff --git a/docs/reference/04-process_executions_and_logs.md b/docs/reference/04-process_executions_and_logs.md index 3006440..6ff4eec 100644 --- a/docs/reference/04-process_executions_and_logs.md +++ b/docs/reference/04-process_executions_and_logs.md @@ -193,9 +193,6 @@ clever_age_process: # ... ``` -Invalid `logs` options raise an options resolver error when the purge command runs (since 3.3, they are no longer -reported by the process list): run the command with `--dry-run` after changing them. - Schedule the command, e.g. every night with a cron: ``` diff --git a/docs/reference/09-notifications.md b/docs/reference/09-notifications.md index 2d4bf67..02f42f2 100644 --- a/docs/reference/09-notifications.md +++ b/docs/reference/09-notifications.md @@ -77,9 +77,7 @@ clever_age_process: | `recipients` | `array[]` | Recipients, with an `email` and/or a `phone` (required by the `email` and `sms` channels). Empty: the `admin_recipients` of the notifier. | A key missing (or `null`) in the process options is inherited from the bundle configuration. Invalid process options -raise an options resolver error when the notification is sent, at the end of an execution, in the Messenger worker: -no notification is then sent. Since 3.3, they are no longer reported by the process list, check them when you change -them. +raise an options resolver error. Statuses -------- diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index a0ba05e..d20c0aa 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -152,10 +152,7 @@ public function getUiOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - /** @var UiOptions $options */ - $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'ui')['ui']; - - return $options; + return $this->resolveOptions($configuration->getOptions(), $processCode)['ui']; } /** @@ -203,10 +200,7 @@ public function getNotificationOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - /** @var NotificationOptions $options */ - $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'notification')['notification']; - - return $options; + return $this->resolveOptions($configuration->getOptions(), $processCode)['notification']; } /** @@ -222,102 +216,78 @@ public function getLogsOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - /** @var LogsOptions $options */ - $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'logs')['logs']; - - return $options; + return $this->resolveOptions($configuration->getOptions(), $processCode)['logs']; } /** - * Resolves the given option only: the other ones are only checked as arrays, so that an invalid option (e.g. the - * launch form) does not break the others (e.g. the notification of the executions or the purge of the logs). - * - * @param array $options - * @param 'ui'|'notification'|'logs' $option + * @param array $options * - * @return array{'ui': array, 'notification': array, 'logs': array} + * @return array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} */ - private function resolveOptions(array $options, string $processCode, string $option): array + private function resolveOptions(array $options, string $processCode): array { $resolver = new OptionsResolver(); - $resolver->setDefaults(['notification' => [], 'logs' => [], 'ui' => []]); - foreach (['notification', 'logs', 'ui'] as $name) { - $resolver->setAllowedTypes($name, 'array'); - } - $resolver->setNormalizer($option, match ($option) { - 'notification' => static fn (Options $options, array $notification): array => self::resolveNotificationOptions($notification), - 'logs' => static fn (Options $options, array $logs): array => self::resolveLogsOptions($logs), - 'ui' => static fn (Options $options, array $ui): array => self::resolveUiOptions($ui, $processCode), - }); + $resolver->setDefault('notification', []); + $resolver->setAllowedTypes('notification', 'array'); + $resolver->setNormalizer('notification', static function (Options $options, array $notification): array { + $notificationResolver = new OptionsResolver(); + $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); + $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); + $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); + $notificationResolver->setAllowedValues('statuses', static function (?array $statuses): bool { + /** @var string[]|null $statusValues */ + $statusValues = $statuses; + + return null === $statusValues || [] === array_diff($statusValues, NotificationTrigger::values()); + }); + $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); + $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); + $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { + if (null === $recipients) { + return null; + } + $recipientResolver = new OptionsResolver(); + $recipientResolver->setDefaults(['email' => null, 'phone' => null]); + $recipientResolver->setAllowedTypes('email', ['null', 'string']); + $recipientResolver->setAllowedTypes('phone', ['null', 'string']); - /** - * @var array{'ui': array, 'notification': array, 'logs': array} $resolved - */ - $resolved = $resolver->resolve($options); + $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { + if (null === $options['email'] && null === $phone) { + throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); + } - return $resolved; - } + return $phone; + }); - /** - * @param array $notification - * - * @return array - */ - private static function resolveNotificationOptions(array $notification): array - { - $notificationResolver = new OptionsResolver(); - $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); - $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); - $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); - $notificationResolver->setAllowedValues('statuses', static function (?array $statuses): bool { - /** @var string[]|null $statusValues */ - $statusValues = $statuses; - - return null === $statusValues || [] === array_diff($statusValues, NotificationTrigger::values()); - }); - $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); - $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); - $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { - if (null === $recipients) { - return null; - } - $recipientResolver = new OptionsResolver(); - $recipientResolver->setDefaults(['email' => null, 'phone' => null]); - $recipientResolver->setAllowedTypes('email', ['null', 'string']); - $recipientResolver->setAllowedTypes('phone', ['null', 'string']); - - $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { - if (null === $options['email'] && null === $phone) { - throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); - } + /** @var array> $recipientRows */ + $recipientRows = $recipients; - return $phone; + return array_values(array_map($recipientResolver->resolve(...), $recipientRows)); }); - /** @var array> $recipientRows */ - $recipientRows = $recipients; - - return array_values(array_map($recipientResolver->resolve(...), $recipientRows)); + return $notificationResolver->resolve($notification); }); + $resolver->setDefault('logs', []); + $resolver->setAllowedTypes('logs', 'array'); + $resolver->setNormalizer('logs', static function (Options $options, array $logs): array { + $logsResolver = new OptionsResolver(); + $logsResolver->setDefaults(['database_max_days' => null, 'file_max_days' => null]); + foreach (['database_max_days', 'file_max_days'] as $option) { + $logsResolver->setAllowedTypes($option, ['null', 'int']); + $logsResolver->setAllowedValues($option, static fn (?int $days): bool => null === $days || $days >= 1); + } - return $notificationResolver->resolve($notification); - } - - /** - * @param array $logs - * - * @return array - */ - private static function resolveLogsOptions(array $logs): array - { - $logsResolver = new OptionsResolver(); - $logsResolver->setDefaults(['database_max_days' => null, 'file_max_days' => null]); - foreach (['database_max_days', 'file_max_days'] as $option) { - $logsResolver->setAllowedTypes($option, ['null', 'int']); - $logsResolver->setAllowedValues($option, static fn (?int $days): bool => null === $days || $days >= 1); - } + return $logsResolver->resolve($logs); + }); + $resolver->setDefault('ui', []); + $resolver->setAllowedTypes('ui', 'array'); + $resolver->setNormalizer('ui', static fn (Options $options, array $ui): array => self::resolveUiOptions($ui, $processCode)); + /** + * @var array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} $options + */ + $options = $resolver->resolve($options); - return $logsResolver->resolve($logs); + return $options; } /** diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index bbe9c0c..913d87c 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -723,67 +723,6 @@ public function testDeprecationsAreTriggeredOncePerResolution(): void self::assertSame([$expected, $expected], $deprecations); } - /** - * Each getter resolves only its own option: the deprecations of the UI options are not triggered by the - * notification and logs getters. - */ - public function testNoDeprecationsThroughTheNotificationAndLogsOptions(): void - { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['constraints' => []]])]); - - self::assertSame([], $this->collectDeprecations(static fn () => $manager->getNotificationOptions('test.process'))); - self::assertSame([], $this->collectDeprecations(static fn () => $manager->getLogsOptions('test.process'))); - } - - /** - * An invalid launch form does not break the notification of the executions nor the purge of the logs. - */ - public function testInvalidUiOptionsDoNotBreakTheNotificationAndLogsOptions(): void - { - $manager = $this->createManager(['test.process' => $this->rawProcess([ - 'ui' => ['form' => ['input' => ['type' => 'App\\Missing']]], - 'notification' => ['enabled' => true], - 'logs' => ['file_max_days' => 30], - ])]); - - self::assertTrue($manager->getNotificationOptions('test.process')['enabled'] ?? null); - self::assertSame(30, $manager->getLogsOptions('test.process')['file_max_days'] ?? null); - - $this->expectException(InvalidOptionsException::class); - $manager->getUiOptions('test.process'); - } - - /** - * The other options are only checked as arrays when the UI options are resolved. - */ - public function testInvalidNotificationOptionsDoNotBreakTheUiOptions(): void - { - $manager = $this->createManager(['test.process' => $this->rawProcess([ - 'ui' => ['source' => 'ERP'], - 'notification' => ['enabled' => 'yes'], - 'logs' => ['file_max_days' => 0], - ])]); - - self::assertSame('ERP', $manager->getUiOptions('test.process')['source'] ?? null); - } - - public function testNewFormatIsNotDeprecatedThroughTheNotificationAndLogsOptions(): void - { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => [ - 'ui_launch_mode' => 'form', - 'form' => ['input' => ['type' => 'file'], 'context' => ['delimiter' => ['default' => ',']], 'constraints' => []], - ]])]); - - $notification = null; - $logs = null; - self::assertSame([], $this->collectDeprecations(static function () use ($manager, &$notification, &$logs): void { - $notification = $manager->getNotificationOptions('test.process'); - $logs = $manager->getLogsOptions('test.process'); - })); - self::assertSame(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null], $notification); - self::assertSame(['database_max_days' => null, 'file_max_days' => null], $logs); - } - public function testInvalidLaunchMode(): void { $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'popup']])]); From 399d67b7a01892b49eb12aa35707fd9d4b093f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:02:48 +0200 Subject: [PATCH 07/17] #160 - Form types are given by their FQCN only, drop symfony/doctrine-bridge Review of #168: one naming scheme for the form types, as the Symfony form builder and the draft of #160. - ProcessConfigurationsManager: remove the FORM_TYPE_ALIASES table; the default type is TextType::class; resolveFormType() only checks that the class exists, implements FormTypeInterface and is not a button type. The short names ("text", "choice"...) are refused with an explicit message. - composer.json: symfony/doctrine-bridge is no longer required (it was only needed by the "entity" alias). An EntityType can still be given by its FQCN when the bridge is installed. - Tests, reference documentation, cookbook, upgrade guide and changelog use FQCNs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- UPGRADE.md | 4 +- composer.json | 1 - docs/cookbooks/form_file_upload.md | 8 +- docs/reference/02-process_ui_options.md | 67 ++++++------- src/Manager/ProcessConfigurationsManager.php | 50 +--------- tests/App/config/packages.yaml | 14 +-- tests/Form/Type/LaunchTypeTest.php | 6 +- .../ProcessConfigurationsManagerTest.php | 96 ++++--------------- .../Runtime/ProcessExtensionRuntimeTest.php | 4 +- 10 files changed, 67 insertions(+), 185 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ddfe6a..f807da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index 0900856..7c77c99 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -61,7 +61,7 @@ options: ui_launch_mode: form form: input: - type: file + type: Symfony\Component\Form\Extension\Core\Type\FileType options: constraints: - File: @@ -70,7 +70,7 @@ options: csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] context: delimiter: - type: choice + type: Symfony\Component\Form\Extension\Core\Type\ChoiceType default: ',' options: choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } diff --git a/composer.json b/composer.json index 68a6115..8ea6225 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,6 @@ "symfony/console": "^6.4 || ^7.4 || ^8", "symfony/dependency-injection": "^6.4 || ^7.4 || ^8", "symfony/deprecation-contracts": "^2.5 || ^3", - "symfony/doctrine-bridge": "^6.4 || ^7.4 || ^8", "symfony/doctrine-messenger": "^6.4 || ^7.4 || ^8", "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8", "symfony/event-dispatcher-contracts": "^3", diff --git a/docs/cookbooks/form_file_upload.md b/docs/cookbooks/form_file_upload.md index e4c2004..78f6681 100644 --- a/docs/cookbooks/form_file_upload.md +++ b/docs/cookbooks/form_file_upload.md @@ -19,7 +19,7 @@ clever_age_process: ui_launch_mode: form form: input: - type: file + type: Symfony\Component\Form\Extension\Core\Type\FileType options: label: CSV file constraints: @@ -30,7 +30,7 @@ clever_age_process: csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] context: delimiter: - type: choice + type: Symfony\Component\Form\Extension\Core\Type\ChoiceType default: ',' options: label: Delimiter @@ -61,12 +61,12 @@ clever_age_process: How it works: - `ui_launch_mode: form` makes the "Launch" action of the process list open a form instead of a confirmation modal (see [launch modes](../reference/02-process_ui_options.md#launch-modes)). -- `form.input` with `type: file` turns the `input` field into a file upload. As the process has an `entry_point`, the +- `form.input` with a `FileType` type turns the `input` field into a file upload. As the process has an `entry_point`, the field is required (HTML attribute); its `NotNull` and `File` constraints are checked by the server: the process is not queued while no file is uploaded or the file is not a CSV, and the error is displayed under the field. The uploaded file is saved in the `upload_directory` directory and its path is sent to the `log_file` task (see [launch form](../reference/02-process_ui_options.md#launch-form)). -- `form.context.delimiter` adds a `delimiter` select, pre-selected on `,` +- `form.context.delimiter` (a `ChoiceType`) adds a `delimiter` select, pre-selected on `,` (see [form fields](../reference/02-process_ui_options.md#form-fields)). A value outside of the choices is refused by the field itself. The selected value is injected into the `InputCsvReaderTask` through the `{{ delimiter }}` contextualized option, exactly like `-c delimiter:";"` on the console. diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 59d3d76..0fbb062 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -21,7 +21,7 @@ clever_age_process: ui_launch_mode: form: input: # - type: # default: text + type: # form type, default: TextType options: # form type options, default: [] default: # initial value, default: null context: @@ -91,7 +91,7 @@ The launch form (`/process/launch?process=`) contains: `true`, the default) by a collection of free key/value pairs (both are required when a line is added). Each field or pair becomes a process context value, like `-c key:value` on the `cleverage:process:execute` console command. -The input can be of any type, but only a single uploaded file is saved: when the input is a `file` field, the +The input can be of any type, but only a single uploaded file is saved: when the input is a `FileType` field, the uploaded file is saved to the `upload_directory` directory (see [bundle configuration](01-bundle_configuration.md#container-parameters)) with a random name keeping the original extension, and its absolute path is passed as process input. The file is not removed after the execution. @@ -107,7 +107,7 @@ options: ui_launch_mode: form form: input: - type: file + type: Symfony\Component\Form\Extension\Core\Type\FileType options: label: CSV file constraints: @@ -118,24 +118,24 @@ options: csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] context: delimiter: - type: choice + type: Symfony\Component\Form\Extension\Core\Type\ChoiceType default: ',' options: label: Delimiter choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } dry_run: - type: checkbox + type: Symfony\Component\Form\Extension\Core\Type\CheckboxType default: false options: { required: false } - comment: ~ # a text field without default value + comment: ~ # a TextType field without default value allow_extra_context: true constraints: [] ``` | Key | Type | Default | Description | |----------------------------|------------------------|---------|------------------------------------------------------------------------------------------------------------------| -| `form.input` | field spec, `null` | `null` | The `input` field. `null`: a text field (or the deprecated `entrypoint_type`). | -| `form.context` | map of field specs | `[]` | The declared context fields, by context key, in their display order. `null` is a text field without default. | +| `form.input` | field spec, `null` | `null` | The `input` field. `null`: a `TextType` field (or the deprecated `entrypoint_type`). | +| `form.context` | map of field specs | `[]` | The declared context fields, by context key, in their display order. `null` is a `TextType` field without default. | | `form.allow_extra_context` | `bool` | `true` | Whether free key/value pairs can be added to the context, for the keys that are not declared. | | `form.constraints` | `array` | `[]` | Constraints applied to the whole form data, see [constraints](#constraints). | @@ -145,7 +145,7 @@ A field spec (`form.input`, `form.context.`) accepts exactly three keys: | Key | Type | Default | Description | |-----------|----------|---------|----------------------------------------------------------------------------------------------------------------| -| `type` | `string` | `text` | A form type alias (see below) or the FQCN of a Symfony form type. | +| `type` | `string` | `TextType` | The FQCN of a Symfony form type (e.g. `Symfony\Component\Form\Extension\Core\Type\ChoiceType`). | | `options` | `array` | `[]` | The options of the form type (`label`, `help`, `attr`, `choices`, `constraints`...). | | `default` | `mixed` | `null` | The initial value of the field. | @@ -153,22 +153,12 @@ The context keys must be valid form field names (letters, digits, `_`, `-` and ` or `_`). A purely numeric key (e.g. `123`, even quoted in YAML) is not supported: PHP casts it to an integer. The `extra` key (the free context pairs) is reserved. -The type aliases are the block prefixes of the Symfony core form types, and `entity`: - -| Alias | Form type | -|-----------------------------------------------------------------------------|---------------------------------------------------------------------------------| -| `text`, `textarea`, `email`, `password`, `search`, `url`, `tel`, `hidden` | `TextType`, `TextareaType`, `EmailType`, `PasswordType`, `SearchType`, `UrlType`, `TelType`, `HiddenType` | -| `integer`, `number`, `money`, `percent`, `range` | `IntegerType`, `NumberType`, `MoneyType`, `PercentType`, `RangeType` | -| `checkbox`, `radio`, `choice`, `enum` | `CheckboxType`, `RadioType`, `ChoiceType`, `EnumType` | -| `country`, `currency`, `language`, `locale`, `timezone` | `CountryType`, `CurrencyType`, `LanguageType`, `LocaleType`, `TimezoneType` | -| `date`, `datetime`, `time`, `birthday`, `week`, `dateinterval` | `DateType`, `DateTimeType`, `TimeType`, `BirthdayType`, `WeekType`, `DateIntervalType` | -| `color`, `file`, `uuid`, `ulid`, `collection`, `repeated` | `ColorType`, `FileType`, `UuidType`, `UlidType`, `CollectionType`, `RepeatedType` | -| `entity` | `Symfony\Bridge\Doctrine\Form\Type\EntityType` | - -The aliases are case-sensitive. Any other type is a FQCN: it must implement `Symfony\Component\Form\FormTypeInterface` -and must not be a button type. Only the existence of the class is checked when the options are resolved: a form type -with dependencies must be a service (tag `form.type`, autoconfigured by default), and an option that the type does -not define (e.g. `choices` on a `text` field) fails when the form is built (500 on the launch page). +`type` is the FQCN of a form type: a Symfony core type (`Symfony\Component\Form\Extension\Core\Type\*`), a type of a +bridge (e.g. `Symfony\Bridge\Doctrine\Form\Type\EntityType`, if installed) or a type of your project. It must implement +`Symfony\Component\Form\FormTypeInterface` and must not be a button type; the short names (`text`, `choice`...) are +not supported. Only the existence of the class is checked when the options are resolved: a form type with +dependencies must be a service (tag `form.type`, autoconfigured by default), and an option that the type does not +define (e.g. `choices` on a `TextType` field) fails when the form is built (500 on the launch page). ### Constraints of a field @@ -179,7 +169,7 @@ The `extensions` option of the `File` constraint also checks the detected MIME t often detected as `text/plain` (semicolon delimiter, single column, few lines): list the accepted MIME types per extension (`extensions: { csv: ['text/csv', 'text/plain'] }`). -`options.required` is only the HTML `required` attribute (Symfony default: `true`, including for a `checkbox`, which +`options.required` is only the HTML `required` attribute (Symfony default: `true`, including for a `CheckboxType`, which must then be checked): the server-side validation relies on `options.constraints` (e.g. `NotBlank`). The `data`, `mapped`, `property_path` and `inherit_data` options are not supported (they break the mapping of the @@ -188,16 +178,16 @@ context and of the default values): use `default` for the initial value. ### Default values of the fields `default` is the initial value of the field, in the model format of its type: -- a `file` field cannot have a default value; -- for `date`, `datetime` and `time`, the YAML value is a string: set `options.input: string` (with the matching +- a `FileType` field cannot have a default value; +- for `DateType`, `DateTimeType` and `TimeType`, the YAML value is a string: set `options.input: string` (with the matching `options.input_format`), or keep a `null` default, otherwise the string cannot be transformed; -- for `choice`, the default value is one of the choices values; -- for `dateinterval`, the YAML value is a string: set `options.input: string` (an ISO 8601 duration such as `P1D`), or +- for `ChoiceType`, the default value is one of the choices values; +- for `DateIntervalType`, the YAML value is a string: set `options.input: string` (an ISO 8601 duration such as `P1D`), or keep a `null` default. ### File fields -A `file` field is only supported for the input, and for a single file (`options.multiple: true` is refused). A form +A `FileType` field is only supported for the input, and for a single file (`options.multiple: true` is refused). A form type whose parent is `FileType` (through `getParent()`) is not detected as a file field. ### Free context pairs @@ -234,12 +224,13 @@ constraints apply to the typed values): | `Stringable` object | its string | | other object | refused (`LogicException`, 500) | -A declared field left empty gives the `null` value (`false` for a `checkbox`). +A declared field left empty gives the `null` value (`false` for a `CheckboxType`). -> **`entity` fields.** The data of an `EntityType` is the entity itself: the `choice_value` option only changes the -> HTML value of the options, not the data given to the process. The entity must implement `\Stringable`: its string -> becomes the context value. Otherwise the launch fails with a `LogicException` ("cannot be used as a process context -> value"). Prefer a `choice` field with scalar choices (e.g. the identifiers of the entities). +> **Object data.** The data of some form types is an object (e.g. the entity of an `EntityType`): the `choice_value` +> option only changes the HTML value of the options, not the data given to the process. Such an object must +> implement `\Stringable`: its string becomes the context value. Otherwise the launch fails with a `LogicException` +> ("cannot be used as a process context value"). Prefer a `ChoiceType` field with scalar choices (e.g. the +> identifiers of the entities). Default values -------------- @@ -276,7 +267,7 @@ Constraints `form.constraints` (or the deprecated `constraints`) uses the same syntax as the [ValidatorTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/validator_task.md) of the process bundle (and as Symfony YAML validation mapping): a list of constraint short names (or FQCN) with their options. Constraints are applied to the whole form data, which is an array with two keys: -- `input`: the data of the input field (e.g. a string, or the `UploadedFile` object of a `file` field), +- `input`: the data of the input field (e.g. a string, or the `UploadedFile` object of a `FileType` field), - `context`: the context as an associative array (`key => value`), the declared keys first, with their typed values. A violation on a declared context key (`[context][]`) is displayed under its field. @@ -290,7 +281,7 @@ options: ui_launch_mode: form form: input: - type: file + type: Symfony\Component\Form\Extension\Core\Type\FileType context: delimiter: default: ',' diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index d20c0aa..61cfa00 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -18,7 +18,6 @@ use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\ButtonTypeInterface; use Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\FormConfigBuilder; @@ -77,49 +76,6 @@ */ final readonly class ProcessConfigurationsManager { - /** - * The form type aliases of the launch form fields: the block prefixes of the Symfony core data types, and "entity". - * - * @var array - */ - private const FORM_TYPE_ALIASES = [ - 'text' => Type\TextType::class, - 'textarea' => Type\TextareaType::class, - 'email' => Type\EmailType::class, - 'password' => Type\PasswordType::class, - 'search' => Type\SearchType::class, - 'url' => Type\UrlType::class, - 'tel' => Type\TelType::class, - 'hidden' => Type\HiddenType::class, - 'integer' => Type\IntegerType::class, - 'number' => Type\NumberType::class, - 'money' => Type\MoneyType::class, - 'percent' => Type\PercentType::class, - 'range' => Type\RangeType::class, - 'checkbox' => Type\CheckboxType::class, - 'radio' => Type\RadioType::class, - 'choice' => Type\ChoiceType::class, - 'enum' => Type\EnumType::class, - 'country' => Type\CountryType::class, - 'currency' => Type\CurrencyType::class, - 'language' => Type\LanguageType::class, - 'locale' => Type\LocaleType::class, - 'timezone' => Type\TimezoneType::class, - 'date' => Type\DateType::class, - 'datetime' => Type\DateTimeType::class, - 'time' => Type\TimeType::class, - 'birthday' => Type\BirthdayType::class, - 'week' => Type\WeekType::class, - 'dateinterval' => Type\DateIntervalType::class, - 'color' => Type\ColorType::class, - 'file' => Type\FileType::class, - 'uuid' => Type\UuidType::class, - 'ulid' => Type\UlidType::class, - 'collection' => Type\CollectionType::class, - 'repeated' => Type\RepeatedType::class, - 'entity' => EntityType::class, - ]; - /** * The form options of a launch form field that break the mapping of the context or the default values. */ @@ -404,7 +360,7 @@ private static function resolveForm(array $form, string $processCode): array private static function resolveField(array $spec, string $path, string $processCode, bool $isInput): array { $fieldResolver = new OptionsResolver(); - $fieldResolver->setDefaults(['type' => 'text', 'options' => [], 'default' => null]); + $fieldResolver->setDefaults(['type' => Type\TextType::class, 'options' => [], 'default' => null]); $fieldResolver->setAllowedTypes('type', 'string'); $fieldResolver->setAllowedTypes('options', 'array'); $fieldResolver->setNormalizer('type', static fn (Options $options, string $type): string => self::resolveFormType($type, $path, $processCode)); @@ -450,9 +406,9 @@ private static function resolveField(array $spec, string $path, string $processC */ private static function resolveFormType(string $type, string $path, string $processCode): string { - $class = self::FORM_TYPE_ALIASES[$type] ?? ltrim($type, '\\'); + $class = ltrim($type, '\\'); if (!class_exists($class)) { - throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is neither a form type alias (%s) nor an existing class.', $type, $path, $processCode, implode(', ', array_map(static fn (string $alias): string => \sprintf('"%s"', $alias), array_keys(self::FORM_TYPE_ALIASES))))); + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must be the FQCN of a form type, the class does not exist.', $type, $path, $processCode)); } if (!is_subclass_of($class, FormTypeInterface::class)) { throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must implement "%s".', $type, $path, $processCode, FormTypeInterface::class)); diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index 1d68150..a5f6ff6 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -102,12 +102,12 @@ clever_age_process: form: context: delimiter: - type: choice + type: Symfony\Component\Form\Extension\Core\Type\ChoiceType default: ',' options: choices: { 'Comma': ',', 'Semicolon': ';' } dry_run: - type: checkbox + type: Symfony\Component\Form\Extension\Core\Type\CheckboxType options: { required: false } tasks: data: @@ -121,10 +121,10 @@ clever_age_process: form: context: from: - type: date + type: Symfony\Component\Form\Extension\Core\Type\DateType options: { widget: single_text, input: datetime_immutable, required: false } to: - type: date + type: Symfony\Component\Form\Extension\Core\Type\DateType default: '2026-01-02' options: { widget: single_text, input: string } tasks: @@ -138,7 +138,7 @@ clever_age_process: ui_launch_mode: form form: input: - type: file + type: Symfony\Component\Form\Extension\Core\Type\FileType options: constraints: - File: @@ -147,7 +147,7 @@ clever_age_process: csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] context: delimiter: - type: choice + type: Symfony\Component\Form\Extension\Core\Type\ChoiceType default: ',' options: choices: { 'Comma': ',', 'Semicolon': ';' } @@ -199,7 +199,7 @@ clever_age_process: form: context: choice: - type: enum + type: Symfony\Component\Form\Extension\Core\Type\EnumType options: { class: CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum } tasks: data: diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php index 43b0421..057a3e2 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -62,12 +62,12 @@ protected function getExtensions(): array 'input' => ['options' => ['required' => false]], 'context' => [ 'delimiter' => [ - 'type' => 'choice', + 'type' => ChoiceType::class, 'default' => ',', 'options' => ['label' => 'Delimiter', 'choices' => ['Comma' => ',', 'Semicolon' => ';']], ], - 'dry_run' => ['type' => 'checkbox', 'default' => false, 'options' => ['required' => false]], - 'limit' => ['type' => 'integer', 'options' => ['constraints' => [['Positive' => null]]]], + 'dry_run' => ['type' => CheckboxType::class, 'default' => false, 'options' => ['required' => false]], + 'limit' => ['type' => IntegerType::class, 'options' => ['constraints' => [['Positive' => null]]]], ], ]]], 'data'), 'strict.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'form', 'form' => [ diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 913d87c..96e01ec 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -24,8 +24,7 @@ use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; -use Symfony\Component\Form\Extension\Core\Type; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -45,47 +44,6 @@ #[UsesClass(NotificationTrigger::class)] class ProcessConfigurationsManagerTest extends TestCase { - /** - * The form type aliases of the launch form fields, in their documented order. - */ - private const FORM_TYPE_ALIASES = [ - 'text' => TextType::class, - 'textarea' => TextareaType::class, - 'email' => Type\EmailType::class, - 'password' => Type\PasswordType::class, - 'search' => Type\SearchType::class, - 'url' => Type\UrlType::class, - 'tel' => Type\TelType::class, - 'hidden' => Type\HiddenType::class, - 'integer' => Type\IntegerType::class, - 'number' => Type\NumberType::class, - 'money' => Type\MoneyType::class, - 'percent' => Type\PercentType::class, - 'range' => Type\RangeType::class, - 'checkbox' => Type\CheckboxType::class, - 'radio' => Type\RadioType::class, - 'choice' => ChoiceType::class, - 'enum' => Type\EnumType::class, - 'country' => Type\CountryType::class, - 'currency' => Type\CurrencyType::class, - 'language' => Type\LanguageType::class, - 'locale' => Type\LocaleType::class, - 'timezone' => Type\TimezoneType::class, - 'date' => Type\DateType::class, - 'datetime' => Type\DateTimeType::class, - 'time' => Type\TimeType::class, - 'birthday' => Type\BirthdayType::class, - 'week' => Type\WeekType::class, - 'dateinterval' => Type\DateIntervalType::class, - 'color' => Type\ColorType::class, - 'file' => FileType::class, - 'uuid' => Type\UuidType::class, - 'ulid' => Type\UlidType::class, - 'collection' => Type\CollectionType::class, - 'repeated' => Type\RepeatedType::class, - 'entity' => EntityType::class, - ]; - public function testPublicAndPrivateProcesses(): void { $manager = $this->createManager([ @@ -225,35 +183,14 @@ public function testInvalidEntrypointType(): void $this->resolveUi(['entrypoint_type' => 'csv']); } - public function testFormInputAlias(): void + public function testFormInput(): void { - [$options, $deprecations] = $this->resolveUi(['form' => ['input' => ['type' => 'file', 'options' => ['label' => 'CSV file']]]]); + [$options, $deprecations] = $this->resolveUi(['form' => ['input' => ['type' => FileType::class, 'options' => ['label' => 'CSV file']]]]); self::assertSame([], $deprecations); self::assertSame(['type' => FileType::class, 'options' => ['label' => 'CSV file'], 'default' => null], $options['form']['input']); } - /** - * @param class-string $class - */ - #[DataProvider('provideFormTypeAliases')] - public function testFormTypeAlias(string $alias, string $class): void - { - [$options] = $this->resolveUi(['form' => ['input' => ['type' => $alias]]]); - - self::assertSame($class, $options['form']['input']['type']); - } - - /** - * @return iterable - */ - public static function provideFormTypeAliases(): iterable - { - foreach (self::FORM_TYPE_ALIASES as $alias => $class) { - yield $alias => [$alias, $class]; - } - } - /** * @param class-string $class */ @@ -281,13 +218,12 @@ public function testInvalidFormType(string $type, string $message): void */ public static function provideInvalidFormTypes(): iterable { - $aliases = implode(', ', array_map(static fn (string $alias): string => \sprintf('"%s"', $alias), array_keys(self::FORM_TYPE_ALIASES))); - $unknown = static fn (string $type): string => \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is neither a form type alias (%s) nor an existing class.', $type, $aliases); + $unknown = static fn (string $type): string => \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" must be the FQCN of a form type, the class does not exist.', $type); - yield 'unknown alias' => ['foo', $unknown('foo')]; + // The short names of the form types are not supported + yield 'short name' => ['file', $unknown('file')]; yield 'missing class' => ['App\Missing', $unknown('App\Missing')]; yield 'not a form type' => [\stdClass::class, 'The type "stdClass" of the launch form field "form.input" of the process "test.process" must implement "Symfony\Component\Form\FormTypeInterface".']; - yield 'button alias' => ['submit', $unknown('submit')]; yield 'button class' => [SubmitType::class, \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is a button type, it holds no data.', SubmitType::class)]; } @@ -413,11 +349,11 @@ public function testInvalidFileField(array $form, string $message): void public static function provideInvalidFileFields(): iterable { yield 'file in the context' => [ - ['context' => ['data' => ['type' => 'file']]], + ['context' => ['data' => ['type' => FileType::class]]], 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', ]; yield 'file with a default value' => [ - ['input' => ['type' => 'file', 'default' => 'data.csv']], + ['input' => ['type' => FileType::class, 'default' => 'data.csv']], 'The launch form field "form.input" of the process "test.process" is a file field, it cannot have a default value.', ]; yield 'multiple files' => [ @@ -480,7 +416,7 @@ public function testEntrypointTypeConflict(): void { $this->expectException(InvalidOptionsException::class); $this->expectExceptionMessage('The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.'); - $this->resolveUi(['entrypoint_type' => 'text', 'form' => ['input' => ['type' => 'text']]]); + $this->resolveUi(['entrypoint_type' => 'text', 'form' => ['input' => ['type' => TextType::class]]]); } /** @@ -532,11 +468,11 @@ public static function provideDeprecatedInputs(): iterable ['type' => FileType::class, 'options' => [], 'default' => null], ]; yield 'declared input without default' => [ - ['form' => ['input' => ['type' => 'textarea']], 'default' => ['input' => 'data.csv']], + ['form' => ['input' => ['type' => TextareaType::class]], 'default' => ['input' => 'data.csv']], ['type' => TextareaType::class, 'options' => [], 'default' => 'data.csv'], ]; yield 'declared file input without default' => [ - ['form' => ['input' => ['type' => 'file']], 'default' => ['input' => 'data.csv']], + ['form' => ['input' => ['type' => FileType::class]], 'default' => ['input' => 'data.csv']], ['type' => FileType::class, 'options' => [], 'default' => null], ]; } @@ -544,7 +480,7 @@ public static function provideDeprecatedInputs(): iterable public function testDeprecatedContextDefaultIsFolded(): void { [$options] = $this->resolveUi([ - 'form' => ['context' => ['delimiter' => ['type' => 'text'], 'comment' => null]], + 'form' => ['context' => ['delimiter' => ['type' => TextType::class], 'comment' => null]], 'default' => ['context' => [['key' => 'delimiter', 'value' => ','], ['key' => 'delimiter', 'value' => ';']]], ]); @@ -616,8 +552,8 @@ public function testNewFormatIsNotDeprecated(): void [, $deprecations] = $this->resolveUi([ 'ui_launch_mode' => 'form', 'form' => [ - 'input' => ['type' => 'file', 'options' => ['constraints' => [['NotNull' => null]]]], - 'context' => ['delimiter' => ['type' => 'choice', 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'input' => ['type' => FileType::class, 'options' => ['constraints' => [['NotNull' => null]]]], + 'context' => ['delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',']]]], 'allow_extra_context' => false, 'constraints' => [], ], @@ -666,7 +602,7 @@ public static function provideLaunchFormDefaults(): iterable yield 'new format' => [ ['form' => [ 'input' => ['default' => 'data.csv'], - 'context' => ['delimiter' => ['default' => ','], 'comment' => null, 'dry_run' => ['type' => 'checkbox', 'default' => false]], + 'context' => ['delimiter' => ['default' => ','], 'comment' => null, 'dry_run' => ['type' => CheckboxType::class, 'default' => false]], ]], ['input' => 'data.csv', 'context' => ['delimiter' => ',', 'comment' => null, 'dry_run' => false]], ]; @@ -681,7 +617,7 @@ public static function provideLaunchFormDefaults(): iterable ], ['input' => null, 'context' => ['delimiter' => ';', 'comment' => 'none', 'foo' => 'bar']], ]; - yield 'file input' => [['form' => ['input' => ['type' => 'file']]], ['input' => null, 'context' => []]]; + yield 'file input' => [['form' => ['input' => ['type' => FileType::class]]], ['input' => null, 'context' => []]]; // The last row of a key wins, the free keys keep the order of their first row yield 'duplicated free keys' => [ ['default' => ['context' => [['key' => 'foo', 'value' => 'a'], ['key' => 'bar', 'value' => 'b'], ['key' => 'foo', 'value' => 'c']]]], diff --git a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php index 14bb537..a5a109c 100644 --- a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php +++ b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php @@ -74,8 +74,8 @@ public function testGetUiOptions(): void public function testGetUiOptionsWithAForm(): void { $configuration = new ProcessConfiguration('demo.process', [], ['ui' => ['ui_launch_mode' => 'form', 'form' => [ - 'input' => ['type' => 'file'], - 'context' => ['delimiter' => ['type' => 'choice', 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'input' => ['type' => FileType::class], + 'context' => ['delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',']]]], 'allow_extra_context' => false, ]]]); $registry = $this->createStub(ProcessConfigurationRegistry::class); From 1a2e51ddee043537fe81679ed2b7cd98e8655a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:05:11 +0200 Subject: [PATCH 08/17] #160 - Test a field constraint given by its FQCN Review of #168: documented but not covered. A project-specific constraint (tests/App/Validator/Even) given by its FQCN in form.context..options .constraints: - ProcessConfigurationsManagerTest: built by ConstraintLoader, with and without options; - Functional/LaunchActionTest (new test.form_custom_constraint process): the violation is displayed under the field and nothing is dispatched, a valid value is dispatched. Co-Authored-By: Claude Opus 5.5 --- tests/App/Validator/Even.php | 36 +++++++++++++++++++ tests/App/Validator/EvenValidator.php | 35 ++++++++++++++++++ tests/App/config/packages.yaml | 15 ++++++++ tests/Functional/LaunchActionTest.php | 27 ++++++++++++++ .../ProcessConfigurationsManagerTest.php | 19 ++++++++++ 5 files changed, 132 insertions(+) create mode 100644 tests/App/Validator/Even.php create mode 100644 tests/App/Validator/EvenValidator.php diff --git a/tests/App/Validator/Even.php b/tests/App/Validator/Even.php new file mode 100644 index 0000000..b1142a7 --- /dev/null +++ b/tests/App/Validator/Even.php @@ -0,0 +1,36 @@ +message = $message ?? $this->message; + } +} diff --git a/tests/App/Validator/EvenValidator.php b/tests/App/Validator/EvenValidator.php new file mode 100644 index 0000000..0a1e0c7 --- /dev/null +++ b/tests/App/Validator/EvenValidator.php @@ -0,0 +1,35 @@ +context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->addViolation(); + } +} diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index a5f6ff6..d470647 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -154,6 +154,21 @@ clever_age_process: tasks: data: service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_custom_constraint: + description: 'Process launched with the UI form, with a field constraint given by its FQCN' + options: + ui: + ui_launch_mode: form + form: + context: + count: + type: Symfony\Component\Form\Extension\Core\Type\IntegerType + options: + constraints: + - CleverAge\UiProcessBundle\Tests\App\Validator\Even: ~ + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' test.form_strict: description: 'Process launched with the UI form, without free context' entry_point: data diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index a67012e..4a5e4e6 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -270,6 +270,33 @@ public function testInvalidChoice(): void self::assertSame([], $this->getDispatchedMessages()); } + /** + * A project-specific constraint, given by its FQCN: its violation is displayed under the field. + */ + public function testFieldConstraintClass(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_custom_constraint'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['count' => '3']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame('The value 3 is not even.', $this->getFieldError($crawler, 'launch_context_count')); + self::assertSame([], $this->getDispatchedMessages()); + + $values['launch']['context'] = ['count' => '4']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame(['execution_user' => 'user@example.com', 'count' => 4], $messages[0]->context); + } + public function testExtraContextRowWithADeclaredKey(): void { $this->login(); diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 96e01ec..bb31398 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -19,6 +19,7 @@ use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; +use CleverAge\UiProcessBundle\Tests\App\Validator\Even; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; @@ -245,6 +246,24 @@ public function testFieldConstraints(): void } } + /** + * A project-specific constraint, given by its FQCN. + */ + public function testFieldConstraintClass(): void + { + [$options] = $this->resolveUi(['form' => ['context' => ['count' => ['options' => ['constraints' => [ + [Even::class => null], + [Even::class => ['message' => 'Odd value.']], + ]]]]]]); + + $constraints = $options['form']['context']['count']['options']['constraints']; + self::assertIsArray($constraints); + self::assertCount(2, $constraints); + self::assertInstanceOf(Even::class, $constraints[0]); + self::assertInstanceOf(Even::class, $constraints[1]); + self::assertSame('Odd value.', $constraints[1]->message); + } + public function testFieldConstraintsMustBeAnArray(): void { $this->expectException(InvalidOptionsException::class); From ef621a2942f2d94c0dd80178a3440b298e54dbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:11:53 +0200 Subject: [PATCH 09/17] #160 - Resolve the launch form in an internal resolver, only on the launch page (process and executions lists unaffected) Review of #168: the process list resolved the UI options of every process, so one invalid "form" option broke the whole page (as the executions list, through the source/target), and each rendering triggered the deprecations of every process using the deprecated options. ProcessConfigurationsManager also grew to ~700 lines, mostly for the launch form. - New internal LaunchFormOptionsResolver: resolves the "form" UI option, checks its conflicts with the deprecated "entrypoint_type", "default" and "constraints" options, folds them into the launch form, builds the initial data of the form, and triggers the deprecations (same messages). - ProcessConfigurationsManager::getUiOptions() is back to its shape of main: "form" is accepted as is, not resolved; no deprecation is triggered. - New internal ProcessConfigurationsManager::getLaunchFormOptions(), used by LaunchAction; LaunchType receives the resolved launch form through its internal "launch_form" option (was "ui_options"). - getLaunchFormDefaults() and buildLaunchFormDefaults() are removed: the initial data is part of the resolved launch form. - Tests: the launch form tests move to LaunchFormOptionsResolverTest; the manager and Twig runtime tests are back to main (plus "form" not resolved); the process list triggers no deprecation. - Reference documentation, upgrade guide and changelog updated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- UPGRADE.md | 4 +- docs/reference/02-process_ui_options.md | 7 +- src/Controller/Admin/Process/LaunchAction.php | 7 +- src/Form/LaunchFormOptionsResolver.php | 306 ++++++++ src/Form/Type/LaunchType.php | 16 +- src/Manager/ProcessConfigurationsManager.php | 376 ++-------- tests/Form/LaunchFormOptionsResolverTest.php | 684 ++++++++++++++++++ tests/Form/Type/LaunchTypeTest.php | 22 +- tests/Functional/FunctionalTestCase.php | 22 + tests/Functional/LaunchActionTest.php | 22 +- tests/Functional/ProcessListTest.php | 14 + .../ProcessConfigurationsManagerTest.php | 681 ++--------------- .../Runtime/ProcessExtensionRuntimeTest.php | 67 +- 14 files changed, 1194 insertions(+), 1036 deletions(-) create mode 100644 src/Form/LaunchFormOptionsResolver.php create mode 100644 tests/Form/LaunchFormOptionsResolverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f807da2..1ef78df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index 7c77c99..070be44 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -86,8 +86,8 @@ options: (`_launch_context_entry_*` becomes `_launch_context_extra_entry_*`), the form type extensions and the code using `$form->get('context')`. - The duplicated keys of `default.context` are merged, the last pair wins (two pairs were displayed). -- A process using a deprecated option triggers its deprecations each time its UI options are resolved (process list, - launch form, executions list): expect them in the deprecation logs until the process is migrated. +- A process using a deprecated option triggers its deprecations each time its launch form is displayed: expect them + in the deprecation logs until the process is migrated. ## v3.0 diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 0fbb062..f3d444f 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -41,7 +41,9 @@ clever_age_process: ``` Every key is optional: a process without `options.ui` is displayed and launched with a confirmation modal. Unknown -keys, or invalid values, raise an options resolver error when the process list is displayed. +keys, or invalid values, raise an options resolver error when the process list is displayed. The `form` option (and +its interaction with the deprecated options) is only resolved when the launch form of the process is displayed: an +invalid `form` option only breaks the launch page of its process. The `notification` key, next to `ui`, configures the [notification](09-notifications.md) of the end of the process executions. The `logs` key, next to `ui`, configures the [log retention](04-process_executions_and_logs.md#log-retention) @@ -314,8 +316,7 @@ Migrating from the deprecated options ------------------------------------- Since 3.3, `entrypoint_type`, `default` and `constraints` are deprecated (a deprecation is triggered each time the -UI options of the process are resolved: process list, launch form, executions list; when the option is set, even to -an empty value), and will be removed in 4.0: +launch form of the process is displayed, when the option is set, even to an empty value), and will be removed in 4.0: | Deprecated option | Replacement | |---------------------|-----------------------------------------------------------------------------------------------| diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index 9f16b9b..3da69df 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -73,18 +73,19 @@ public function __invoke( return $this->redirectToRoute('cleverage_ui_process', ['routeName' => 'cleverage_ui_process_list']); } + $launchForm = $this->processConfigurationsManager->getLaunchFormOptions($processCode) ?? throw MissingProcessException::create($processCode); $form = $this->createForm( LaunchType::class, null, [ - 'constraints' => $uiOptions['form']['constraints'], + 'constraints' => $launchForm['constraints'], 'process_code' => $processCode, // Not resolved again by the form type - 'ui_options' => $uiOptions, + 'launch_form' => $launchForm, ] ); if (false === $form->isSubmitted()) { - $form->setData($this->processConfigurationsManager->buildLaunchFormDefaults($uiOptions)); + $form->setData($launchForm['data']); } $form->handleRequest($this->requestStack->getMainRequest()); if ($form->isSubmitted() && $form->isValid()) { diff --git a/src/Form/LaunchFormOptionsResolver.php b/src/Form/LaunchFormOptionsResolver.php new file mode 100644 index 0000000..91a5d56 --- /dev/null +++ b/src/Form/LaunchFormOptionsResolver.php @@ -0,0 +1,306 @@ +>, + * 'options': array, + * 'default': mixed + * } + * @phpstan-type LaunchFormData array{'input': mixed, 'context': array} + * @phpstan-type LaunchFormOptions array{ + * 'input': LaunchFormField, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[], + * 'data': LaunchFormData + * } + * @phpstan-type ResolvedForm array{ + * 'input': LaunchFormField|null, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[] + * } + */ +final class LaunchFormOptionsResolver +{ + /** + * The form options of a launch form field that break the mapping of the context or the default values. + */ + private const UNSUPPORTED_FIELD_OPTIONS = ['data', 'mapped', 'property_path', 'inherit_data']; + + /** + * The deprecated UI options, with their replacement. + */ + private const DEPRECATED_OPTIONS = [ + 'entrypoint_type' => '"form.input.type"', + 'constraints' => '"form.constraints"', + 'default' => '"form.input.default" and "form.context..default"', + ]; + + /** + * @param array $ui the raw UI options of the process, to know whether an option is given + * @param UiOptions $uiOptions the UI options of the process, as resolved by getUiOptions() + * + * @return LaunchFormOptions + */ + public function resolve(array $ui, array $uiOptions, string $processCode): array + { + foreach (self::DEPRECATED_OPTIONS as $option => $replacement) { + if (\array_key_exists($option, $ui)) { + trigger_deprecation('cleverage/ui-process-bundle', '3.3', 'The "%s" UI option of the process "%s" is deprecated, use %s instead.', $option, $processCode, $replacement); + } + } + + $rawForm = $ui['form'] ?? []; + if (!\is_array($rawForm)) { + throw new InvalidOptionsException(\sprintf('The "form" UI option of the process "%s" must be an array, "%s" given.', $processCode, get_debug_type($rawForm))); + } + $form = $this->foldDeprecatedOptions($ui, $rawForm, $this->resolveForm($rawForm, $processCode), $uiOptions, $processCode); + $form['data'] = $this->buildData($form, $uiOptions); + + return $form; + } + + /** + * @param array $form + * + * @return ResolvedForm + */ + private function resolveForm(array $form, string $processCode): array + { + $formResolver = new OptionsResolver(); + $formResolver->setDefaults(['input' => null, 'context' => [], 'allow_extra_context' => true, 'constraints' => []]); + $formResolver->setAllowedTypes('input', ['null', 'array']); + $formResolver->setAllowedTypes('context', 'array'); + $formResolver->setAllowedTypes('allow_extra_context', 'bool'); + $formResolver->setAllowedTypes('constraints', 'array'); + $formResolver->setNormalizer( + 'input', + fn (Options $options, ?array $input): ?array => null === $input ? null : $this->resolveField($input, 'form.input', $processCode, true) + ); + $formResolver->setNormalizer('context', function (Options $options, array $context) use ($processCode): array { + $fields = []; + foreach ($context as $key => $spec) { + if (\is_int($key)) { + throw new InvalidOptionsException(\sprintf('The context key "%d" of "form.context" of the process "%s" is not supported: purely numeric context keys are cast to integers by PHP.', $key, $processCode)); + } + if ('' === $key || !FormConfigBuilder::isValidName($key)) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key, $processCode)); + } + // "extra" is the child of the free context pairs + if ('extra' === $key) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is reserved.', $key, $processCode)); + } + if (null !== $spec && !\is_array($spec)) { + throw new InvalidOptionsException(\sprintf('The launch form field "form.context.%s" of the process "%s" must be an array or null, "%s" given.', $key, $processCode, get_debug_type($spec))); + } + $fields[$key] = $this->resolveField($spec ?? [], "form.context.$key", $processCode, false); + } + + return $fields; + }); + $formResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); + + /** @var ResolvedForm $resolved */ + $resolved = $formResolver->resolve($form); + + return $resolved; + } + + /** + * @param array $spec + * + * @return LaunchFormField + */ + private function resolveField(array $spec, string $path, string $processCode, bool $isInput): array + { + $fieldResolver = new OptionsResolver(); + $fieldResolver->setDefaults(['type' => TextType::class, 'options' => [], 'default' => null]); + $fieldResolver->setAllowedTypes('type', 'string'); + $fieldResolver->setAllowedTypes('options', 'array'); + $fieldResolver->setNormalizer('type', fn (Options $options, string $type): string => $this->resolveFormType($type, $path, $processCode)); + $fieldResolver->setNormalizer('options', static function (Options $options, array $fieldOptions) use ($path, $processCode): array { + foreach (self::UNSUPPORTED_FIELD_OPTIONS as $option) { + if (\array_key_exists($option, $fieldOptions)) { + throw new InvalidOptionsException(\sprintf('The "%s" form option of the launch form field "%s" of the process "%s" is not supported, use "default" for the initial value.', $option, $path, $processCode)); + } + } + if (\array_key_exists('constraints', $fieldOptions)) { + if (!\is_array($fieldOptions['constraints'])) { + throw new InvalidOptionsException(\sprintf('The "constraints" form option of the launch form field "%s" of the process "%s" must be an array of constraints.', $path, $processCode)); + } + $fieldOptions['constraints'] = (new ConstraintLoader())->buildConstraints($fieldOptions['constraints']); + } + + return $fieldOptions; + }); + + /** @var LaunchFormField $resolved */ + $resolved = $fieldResolver->resolve($spec); + $field = ['type' => $resolved['type'], 'options' => $resolved['options'], 'default' => $resolved['default']]; + + if (is_a($field['type'], FileType::class, true)) { + // A temporary file, removed at the end of the request, would end as a string in the context + if (!$isInput) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field: file fields are only supported for the input.', $path, $processCode)); + } + if (null !== $field['default']) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field, it cannot have a default value.', $path, $processCode)); + } + // The launch action handles a single uploaded file + if (true === ($field['options']['multiple'] ?? false)) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" cannot upload multiple files.', $path, $processCode)); + } + } + + return $field; + } + + /** + * @return class-string> + */ + private function resolveFormType(string $type, string $path, string $processCode): string + { + $class = ltrim($type, '\\'); + if (!class_exists($class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must be the FQCN of a form type, the class does not exist.', $type, $path, $processCode)); + } + if (!is_subclass_of($class, FormTypeInterface::class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must implement "%s".', $type, $path, $processCode, FormTypeInterface::class)); + } + // SubmitType implements SubmitButtonTypeInterface, which does not extend ButtonTypeInterface + if (is_subclass_of($class, ButtonTypeInterface::class) || is_subclass_of($class, SubmitButtonTypeInterface::class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is a button type, it holds no data.', $type, $path, $processCode)); + } + + return $class; + } + + /** + * Checks the conflicts between the deprecated UI options and the "form" option, and folds the deprecated options + * into the effective launch form. + * + * @param array $ui the raw UI options, to know whether an option is given + * @param array $rawForm the raw "form" option + * @param ResolvedForm $form the resolved "form" option + * @param UiOptions $uiOptions the resolved UI options (the deprecated options) + * + * @return array{ + * 'input': LaunchFormField, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[] + * } + */ + private function foldDeprecatedOptions(array $ui, array $rawForm, array $form, array $uiOptions, string $processCode): array + { + /** @var array|null $rawInput */ + $rawInput = $rawForm['input'] ?? null; + /** @var array|null> $rawContext */ + $rawContext = $rawForm['context'] ?? []; + /** @var array $rawDefault */ + $rawDefault = $ui['default'] ?? []; + + if (\array_key_exists('entrypoint_type', $ui) && null !== $rawInput) { + throw new InvalidOptionsException(\sprintf('The "entrypoint_type" and "form.input" UI options of the process "%s" cannot be used together, use "form.input" only.', $processCode)); + } + if (\array_key_exists('constraints', $ui) && \array_key_exists('constraints', $rawForm)) { + throw new InvalidOptionsException(\sprintf('The "constraints" and "form.constraints" UI options of the process "%s" cannot be used together, use "form.constraints" only.', $processCode)); + } + $hasDefaultInput = \array_key_exists('input', $rawDefault); + if ($hasDefaultInput && \array_key_exists('default', $rawInput ?? [])) { + throw new InvalidOptionsException(\sprintf('The default value of the input of the process "%s" is set both in "default.input" and in "form.input.default", use "form.input.default" only.', $processCode)); + } + // The last row of a key wins + $defaultContext = array_column($uiOptions['default']['context'], 'value', 'key'); + foreach (array_keys($defaultContext) as $key) { + $key = (string) $key; + if (\array_key_exists($key, $form['context'])) { + if (\array_key_exists('default', $rawContext[$key] ?? [])) { + throw new InvalidOptionsException(\sprintf('The default value of the context key "%1$s" of the process "%2$s" is set both in "default.context" and in "form.context.%1$s.default", use "form.context.%1$s.default" only.', $key, $processCode)); + } + } elseif (false === $form['allow_extra_context']) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "default.context" of the process "%s" is not declared in "form.context", and "form.allow_extra_context" is false.', $key, $processCode)); + } + } + + $input = $form['input']; + if (null === $input) { + $isFile = 'file' === $uiOptions['entrypoint_type']; + $input = ['type' => $isFile ? FileType::class : TextType::class, 'options' => [], 'default' => $isFile ? null : $uiOptions['default']['input']]; + } elseif ($hasDefaultInput && !\array_key_exists('default', $rawInput ?? []) && !is_a($input['type'], FileType::class, true)) { + $input['default'] = $uiOptions['default']['input']; + } + + $context = $form['context']; + foreach ($context as $key => $field) { + if (\array_key_exists($key, $defaultContext)) { + $context[$key]['default'] = $defaultContext[$key]; + } + } + + return [ + 'input' => $input, + 'context' => $context, + 'allow_extra_context' => $form['allow_extra_context'], + 'constraints' => \array_key_exists('constraints', $rawForm) ? $form['constraints'] : $uiOptions['constraints'], + ]; + } + + /** + * The initial data of the launch form: the default value of the input, and the default values of the context, the + * declared keys first (in their order of declaration), then the free keys of the deprecated "default.context" + * option. + * + * @param array{'input': LaunchFormField, 'context': array} $form + * @param UiOptions $uiOptions + * + * @return LaunchFormData + */ + private function buildData(array $form, array $uiOptions): array + { + $context = array_map(static fn (array $field): mixed => $field['default'], $form['context']); + foreach ($uiOptions['default']['context'] as $row) { + $key = (string) $row['key']; + if (!\array_key_exists($key, $form['context'])) { + $context[$key] = $row['value']; + } + } + + return ['input' => $form['input']['default'], 'context' => $context]; + } +} diff --git a/src/Form/Type/LaunchType.php b/src/Form/Type/LaunchType.php index b29e7ab..25a403c 100644 --- a/src/Form/Type/LaunchType.php +++ b/src/Form/Type/LaunchType.php @@ -16,6 +16,7 @@ use CleverAge\ProcessBundle\Configuration\TaskConfiguration; use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; @@ -28,7 +29,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** - * @phpstan-import-type UiOptions from ProcessConfigurationsManager + * @phpstan-import-type LaunchFormOptions from LaunchFormOptionsResolver * * @template-extends AbstractType> */ @@ -45,9 +46,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void /** @var string $code */ $code = $options['process_code']; $configuration = $this->registry->getProcessConfiguration($code); - /** @var UiOptions $uiOptions */ - $uiOptions = $options['ui_options'] ?? $this->configurationsManager->getUiOptions($code) ?? throw MissingProcessException::create($code); - $form = $uiOptions['form']; + /** @var LaunchFormOptions $form */ + $form = $options['launch_form'] ?? $this->configurationsManager->getLaunchFormOptions($code) ?? throw MissingProcessException::create($code); // A "required" form option of the input takes precedence $builder->add( @@ -118,10 +118,10 @@ public function finishView(FormView $view, FormInterface $form, array $options): public function configureOptions(OptionsResolver $resolver): void { $resolver->setRequired('process_code'); - // The UI options of the process, as resolved by ProcessConfigurationsManager::getUiOptions(): resolved from - // the process code when null - $resolver->setDefault('ui_options', null); - $resolver->setAllowedTypes('ui_options', ['null', 'array']); + // @internal The launch form of the process, as resolved by ProcessConfigurationsManager::getLaunchFormOptions(): + // resolved from the process code when null + $resolver->setDefault('launch_form', null); + $resolver->setAllowedTypes('launch_form', ['null', 'array']); } #[\Override] diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index 61cfa00..51c1130 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -14,44 +14,18 @@ namespace CleverAge\UiProcessBundle\Manager; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; -use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; -use Symfony\Component\Form\ButtonTypeInterface; -use Symfony\Component\Form\Extension\Core\Type; -use Symfony\Component\Form\FormConfigBuilder; -use Symfony\Component\Form\FormTypeInterface; -use Symfony\Component\Form\SubmitButtonTypeInterface; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraint; /** - * @phpstan-type FormFieldOptions array{ - * 'type': class-string>, - * 'options': array, - * 'default': mixed - * } - * @phpstan-type FormOptions array{ - * 'input': FormFieldOptions, - * 'context': array, - * 'allow_extra_context': bool, - * 'constraints': Constraint[] - * } - * @phpstan-type ResolvedFormOptions array{ - * 'input': FormFieldOptions|null, - * 'context': array, - * 'allow_extra_context': bool, - * 'constraints': Constraint[] - * } - * @phpstan-type ResolvedUiOptions array{ - * 'entrypoint_type': string, - * 'constraints': Constraint[], - * 'default': array{'input': mixed, 'context': array}, - * 'form': ResolvedFormOptions - * } + * @phpstan-import-type LaunchFormOptions from LaunchFormOptionsResolver + * * @phpstan-type UiOptions array{ * 'source': ?string, * 'target': ?string, @@ -59,10 +33,9 @@ * 'entrypoint_type': string, * 'constraints': Constraint[], * 'run': ?bool, - * 'default': array{'input': mixed, 'context': array}, - * 'form': FormOptions + * 'default': array{'input': mixed, 'context': array}, + * 'form': mixed * } - * @phpstan-type LaunchFormData array{'input': mixed, 'context': array} * @phpstan-type NotificationOptions array{ * 'enabled': ?bool, * 'statuses': ?string[], @@ -76,13 +49,10 @@ */ final readonly class ProcessConfigurationsManager { - /** - * The form options of a launch form field that break the mapping of the context or the default values. - */ - private const UNSUPPORTED_FIELD_OPTIONS = ['data', 'mapped', 'property_path', 'inherit_data']; - - public function __construct(private ProcessConfigurationRegistry $registry) - { + public function __construct( + private ProcessConfigurationRegistry $registry, + private LaunchFormOptionsResolver $launchFormOptionsResolver = new LaunchFormOptionsResolver(), + ) { } /** @return ProcessConfiguration[] */ @@ -98,6 +68,9 @@ public function getPrivateProcesses(): array } /** + * The "form" option is not resolved (see getLaunchFormOptions()): an invalid launch form only breaks the launch + * page of the process. + * * @return UiOptions|null */ public function getUiOptions(string $processCode): ?array @@ -108,39 +81,27 @@ public function getUiOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions(), $processCode)['ui']; + return $this->resolveOptions($configuration->getOptions())['ui']; } /** - * The initial data of the launch form of the process: the default value of the input, and the default values of - * the context, the declared keys first (in their order of declaration), then the free keys of the deprecated - * "default.context" option. + * The launch form of the process: the "form" UI option, the deprecated "entrypoint_type", "default" and + * "constraints" UI options folded into it (their deprecations are triggered), and the initial data of the form. * - * @return LaunchFormData - */ - public function getLaunchFormDefaults(string $processCode): array - { - return $this->buildLaunchFormDefaults($this->getUiOptions($processCode) ?? throw MissingProcessException::create($processCode)); - } - - /** - * The initial data of the launch form, from the UI options already resolved by getUiOptions(). - * - * @param UiOptions $uiOptions + * @internal * - * @return LaunchFormData + * @return LaunchFormOptions|null */ - public function buildLaunchFormDefaults(array $uiOptions): array + public function getLaunchFormOptions(string $processCode): ?array { - $context = array_map(static fn (array $field): mixed => $field['default'], $uiOptions['form']['context']); - foreach ($uiOptions['default']['context'] as $row) { - $key = (string) $row['key']; - if (!\array_key_exists($key, $uiOptions['form']['context'])) { - $context[$key] = $row['value']; - } + $uiOptions = $this->getUiOptions($processCode); + if (null === $uiOptions) { + return null; } + /** @var array $ui */ + $ui = $this->registry->getProcessConfiguration($processCode)->getOptions()['ui'] ?? []; - return ['input' => $uiOptions['form']['input']['default'], 'context' => $context]; + return $this->launchFormOptionsResolver->resolve($ui, $uiOptions, $processCode); } /** @@ -156,7 +117,7 @@ public function getNotificationOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions(), $processCode)['notification']; + return $this->resolveOptions($configuration->getOptions())['notification']; } /** @@ -172,7 +133,7 @@ public function getLogsOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions(), $processCode)['logs']; + return $this->resolveOptions($configuration->getOptions())['logs']; } /** @@ -180,7 +141,7 @@ public function getLogsOptions(string $processCode): ?array * * @return array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} */ - private function resolveOptions(array $options, string $processCode): array + private function resolveOptions(array $options): array { $resolver = new OptionsResolver(); $resolver->setDefault('notification', []); @@ -237,7 +198,46 @@ private function resolveOptions(array $options, string $processCode): array }); $resolver->setDefault('ui', []); $resolver->setAllowedTypes('ui', 'array'); - $resolver->setNormalizer('ui', static fn (Options $options, array $ui): array => self::resolveUiOptions($ui, $processCode)); + $resolver->setNormalizer('ui', static function (Options $options, array $ui): array { + $uiResolver = new OptionsResolver(); + $uiResolver->setDefaults( + [ + 'source' => null, + 'target' => null, + 'entrypoint_type' => 'text', + 'ui_launch_mode' => 'modal', + 'constraints' => [], + 'run' => null, + 'default' => [], + // Resolved by getLaunchFormOptions() + 'form' => [], + ] + ); + $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); + // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are + // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 + $uiResolver->setAllowedTypes('default', 'array'); + $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { + $defaultResolver = new OptionsResolver(); + $defaultResolver->setDefaults(['input' => null, 'context' => []]); + $defaultResolver->setAllowedTypes('context', 'array[]'); + $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { + $contextResolver = new OptionsResolver(); + $contextResolver->setRequired(['key', 'value']); + + /** @var array> $contextRows */ + $contextRows = $context; + + return array_map($contextResolver->resolve(...), $contextRows); + }); + + return $defaultResolver->resolve($default); + }); + $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); + $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); + + return $uiResolver->resolve($ui); + }); /** * @var array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} $options */ @@ -246,248 +246,6 @@ private function resolveOptions(array $options, string $processCode): array return $options; } - /** - * @param array $ui - * - * @return UiOptions - */ - private static function resolveUiOptions(array $ui, string $processCode): array - { - $uiResolver = new OptionsResolver(); - $uiResolver->setDefaults( - [ - 'source' => null, - 'target' => null, - 'entrypoint_type' => 'text', - 'ui_launch_mode' => 'modal', - 'constraints' => [], - 'run' => null, - 'default' => [], - 'form' => [], - ] - ); - $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); - // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are - // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 - $uiResolver->setAllowedTypes('default', 'array'); - $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { - $defaultResolver = new OptionsResolver(); - $defaultResolver->setDefaults(['input' => null, 'context' => []]); - $defaultResolver->setAllowedTypes('context', 'array[]'); - $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { - $contextResolver = new OptionsResolver(); - $contextResolver->setRequired(['key', 'value']); - - /** @var array> $contextRows */ - $contextRows = $context; - - return array_map($contextResolver->resolve(...), $contextRows); - }); - - return $defaultResolver->resolve($default); - }); - $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); - $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); - $uiResolver->setAllowedTypes('form', 'array'); - $uiResolver->setNormalizer('form', static fn (Options $options, array $form): array => self::resolveForm($form, $processCode)); - // A Closure message: the deprecation is triggered only when the option is given, once per resolution - $uiResolver->setDeprecated('entrypoint_type', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "entrypoint_type" UI option of the process "%s" is deprecated, use "form.input.type" instead.', $processCode)); - $uiResolver->setDeprecated('default', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "default" UI option of the process "%s" is deprecated, use "form.input.default" and "form.context..default" instead.', $processCode)); - $uiResolver->setDeprecated('constraints', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "constraints" UI option of the process "%s" is deprecated, use "form.constraints" instead.', $processCode)); - - /** @var ResolvedUiOptions $resolved */ - $resolved = $uiResolver->resolve($ui); - // The effective "form" option, the deprecated options folded into it - $resolved['form'] = self::foldDeprecatedOptions($ui, $resolved, $processCode); - - /** @var UiOptions $uiOptions */ - $uiOptions = $resolved; - - return $uiOptions; - } - - /** - * @param array $form - * - * @return ResolvedFormOptions - */ - private static function resolveForm(array $form, string $processCode): array - { - $formResolver = new OptionsResolver(); - $formResolver->setDefaults(['input' => null, 'context' => [], 'allow_extra_context' => true, 'constraints' => []]); - $formResolver->setAllowedTypes('input', ['null', 'array']); - $formResolver->setAllowedTypes('context', 'array'); - $formResolver->setAllowedTypes('allow_extra_context', 'bool'); - $formResolver->setAllowedTypes('constraints', 'array'); - $formResolver->setNormalizer( - 'input', - static fn (Options $options, ?array $input): ?array => null === $input ? null : self::resolveField($input, 'form.input', $processCode, true) - ); - $formResolver->setNormalizer('context', static function (Options $options, array $context) use ($processCode): array { - $fields = []; - foreach ($context as $key => $spec) { - if (\is_int($key)) { - throw new InvalidOptionsException(\sprintf('The context key "%d" of "form.context" of the process "%s" is not supported: purely numeric context keys are cast to integers by PHP.', $key, $processCode)); - } - if ('' === $key || !FormConfigBuilder::isValidName($key)) { - throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key, $processCode)); - } - // "extra" is the child of the free context pairs - if ('extra' === $key) { - throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is reserved.', $key, $processCode)); - } - if (null !== $spec && !\is_array($spec)) { - throw new InvalidOptionsException(\sprintf('The launch form field "form.context.%s" of the process "%s" must be an array or null, "%s" given.', $key, $processCode, get_debug_type($spec))); - } - $fields[$key] = self::resolveField($spec ?? [], "form.context.$key", $processCode, false); - } - - return $fields; - }); - $formResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); - - /** @var ResolvedFormOptions $resolved */ - $resolved = $formResolver->resolve($form); - - return $resolved; - } - - /** - * @param array $spec - * - * @return FormFieldOptions - */ - private static function resolveField(array $spec, string $path, string $processCode, bool $isInput): array - { - $fieldResolver = new OptionsResolver(); - $fieldResolver->setDefaults(['type' => Type\TextType::class, 'options' => [], 'default' => null]); - $fieldResolver->setAllowedTypes('type', 'string'); - $fieldResolver->setAllowedTypes('options', 'array'); - $fieldResolver->setNormalizer('type', static fn (Options $options, string $type): string => self::resolveFormType($type, $path, $processCode)); - $fieldResolver->setNormalizer('options', static function (Options $options, array $fieldOptions) use ($path, $processCode): array { - foreach (self::UNSUPPORTED_FIELD_OPTIONS as $option) { - if (\array_key_exists($option, $fieldOptions)) { - throw new InvalidOptionsException(\sprintf('The "%s" form option of the launch form field "%s" of the process "%s" is not supported, use "default" for the initial value.', $option, $path, $processCode)); - } - } - if (\array_key_exists('constraints', $fieldOptions)) { - if (!\is_array($fieldOptions['constraints'])) { - throw new InvalidOptionsException(\sprintf('The "constraints" form option of the launch form field "%s" of the process "%s" must be an array of constraints.', $path, $processCode)); - } - $fieldOptions['constraints'] = (new ConstraintLoader())->buildConstraints($fieldOptions['constraints']); - } - - return $fieldOptions; - }); - - /** @var FormFieldOptions $resolved */ - $resolved = $fieldResolver->resolve($spec); - $field = ['type' => $resolved['type'], 'options' => $resolved['options'], 'default' => $resolved['default']]; - - if (is_a($field['type'], Type\FileType::class, true)) { - // A temporary file, removed at the end of the request, would end as a string in the context - if (!$isInput) { - throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field: file fields are only supported for the input.', $path, $processCode)); - } - if (null !== $field['default']) { - throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field, it cannot have a default value.', $path, $processCode)); - } - // The launch action handles a single uploaded file - if (true === ($field['options']['multiple'] ?? false)) { - throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" cannot upload multiple files.', $path, $processCode)); - } - } - - return $field; - } - - /** - * @return class-string> - */ - private static function resolveFormType(string $type, string $path, string $processCode): string - { - $class = ltrim($type, '\\'); - if (!class_exists($class)) { - throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must be the FQCN of a form type, the class does not exist.', $type, $path, $processCode)); - } - if (!is_subclass_of($class, FormTypeInterface::class)) { - throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must implement "%s".', $type, $path, $processCode, FormTypeInterface::class)); - } - // SubmitType implements SubmitButtonTypeInterface, which does not extend ButtonTypeInterface - if (is_subclass_of($class, ButtonTypeInterface::class) || is_subclass_of($class, SubmitButtonTypeInterface::class)) { - throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is a button type, it holds no data.', $type, $path, $processCode)); - } - - return $class; - } - - /** - * Checks the conflicts between the deprecated UI options and the "form" option, and folds the deprecated options - * into the effective "form" option. - * - * @param array $ui the raw UI options, to know whether an option is given - * @param ResolvedUiOptions $resolved the resolved UI options, before the folding - * - * @return FormOptions - */ - private static function foldDeprecatedOptions(array $ui, array $resolved, string $processCode): array - { - /** @var array $rawForm */ - $rawForm = $ui['form'] ?? []; - /** @var array|null $rawInput */ - $rawInput = $rawForm['input'] ?? null; - /** @var array|null> $rawContext */ - $rawContext = $rawForm['context'] ?? []; - /** @var array $rawDefault */ - $rawDefault = $ui['default'] ?? []; - $form = $resolved['form']; - - if (\array_key_exists('entrypoint_type', $ui) && null !== $rawInput) { - throw new InvalidOptionsException(\sprintf('The "entrypoint_type" and "form.input" UI options of the process "%s" cannot be used together, use "form.input" only.', $processCode)); - } - if (\array_key_exists('constraints', $ui) && \array_key_exists('constraints', $rawForm)) { - throw new InvalidOptionsException(\sprintf('The "constraints" and "form.constraints" UI options of the process "%s" cannot be used together, use "form.constraints" only.', $processCode)); - } - $hasDefaultInput = \array_key_exists('input', $rawDefault); - if ($hasDefaultInput && \array_key_exists('default', $rawInput ?? [])) { - throw new InvalidOptionsException(\sprintf('The default value of the input of the process "%s" is set both in "default.input" and in "form.input.default", use "form.input.default" only.', $processCode)); - } - // The last row of a key wins - $defaultContext = array_column($resolved['default']['context'], 'value', 'key'); - foreach (array_keys($defaultContext) as $key) { - $key = (string) $key; - if (\array_key_exists($key, $form['context'])) { - if (\array_key_exists('default', $rawContext[$key] ?? [])) { - throw new InvalidOptionsException(\sprintf('The default value of the context key "%1$s" of the process "%2$s" is set both in "default.context" and in "form.context.%1$s.default", use "form.context.%1$s.default" only.', $key, $processCode)); - } - } elseif (false === $form['allow_extra_context']) { - throw new InvalidOptionsException(\sprintf('The context key "%s" of "default.context" of the process "%s" is not declared in "form.context", and "form.allow_extra_context" is false.', $key, $processCode)); - } - } - - $input = $form['input']; - if (null === $input) { - $isFile = 'file' === $resolved['entrypoint_type']; - $input = ['type' => $isFile ? Type\FileType::class : Type\TextType::class, 'options' => [], 'default' => $isFile ? null : $resolved['default']['input']]; - } elseif ($hasDefaultInput && !\array_key_exists('default', $rawInput ?? []) && !is_a($input['type'], Type\FileType::class, true)) { - $input['default'] = $resolved['default']['input']; - } - - $context = $form['context']; - foreach ($context as $key => $field) { - if (\array_key_exists($key, $defaultContext)) { - $context[$key]['default'] = $defaultContext[$key]; - } - } - - return [ - 'input' => $input, - 'context' => $context, - 'allow_extra_context' => $form['allow_extra_context'], - 'constraints' => \array_key_exists('constraints', $rawForm) ? $form['constraints'] : $resolved['constraints'], - ]; - } - /** @return ProcessConfiguration[] */ private function getConfigurations(): array { diff --git a/tests/Form/LaunchFormOptionsResolverTest.php b/tests/Form/LaunchFormOptionsResolverTest.php new file mode 100644 index 0000000..5481020 --- /dev/null +++ b/tests/Form/LaunchFormOptionsResolverTest.php @@ -0,0 +1,684 @@ +resolveLaunchForm([]); + + self::assertSame([], $deprecations); + self::assertSame( + [ + 'input' => ['type' => TextType::class, 'options' => [], 'default' => null], + 'context' => [], + 'allow_extra_context' => true, + 'constraints' => [], + 'data' => ['input' => null, 'context' => []], + ], + $form + ); + } + + public function testDeprecatedOptionsAreFolded(): void + { + [$form, $deprecations] = $this->resolveLaunchForm([ + 'entrypoint_type' => 'file', + 'ui_launch_mode' => 'form', + 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], + 'default' => [ + 'input' => 'data.csv', + 'context' => [['key' => 'foo', 'value' => 'bar']], + ], + ]); + + self::assertSame( + [ + self::deprecation('entrypoint_type', 'form.input.type'), + self::deprecation('constraints', 'form.constraints'), + self::deprecation('default', 'form.input.default" and "form.context..default'), + ], + $deprecations + ); + self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $form['input']); + self::assertSame([], $form['context']); + self::assertTrue($form['allow_extra_context']); + self::assertCount(2, $form['constraints']); + self::assertInstanceOf(NotBlank::class, $form['constraints'][0]); + self::assertInstanceOf(Length::class, $form['constraints'][1]); + self::assertSame(['input' => null, 'context' => ['foo' => 'bar']], $form['data']); + } + + public function testFormInput(): void + { + [$form, $deprecations] = $this->resolveLaunchForm(['form' => ['input' => ['type' => FileType::class, 'options' => ['label' => 'CSV file']]]]); + + self::assertSame([], $deprecations); + self::assertSame(['type' => FileType::class, 'options' => ['label' => 'CSV file'], 'default' => null], $form['input']); + } + + /** + * @param class-string $class + */ + #[TestWith([ChoiceType::class, ChoiceType::class])] + #[TestWith([ProcessContextType::class, ProcessContextType::class])] + #[TestWith(['\\'.TextType::class, TextType::class])] + public function testFormTypeClass(string $type, string $class): void + { + [$form] = $this->resolveLaunchForm(['form' => ['input' => ['type' => $type], 'context' => ['foo' => ['type' => $type]]]]); + + self::assertSame($class, $form['input']['type']); + self::assertSame($class, $form['context']['foo']['type']); + } + + #[DataProvider('provideInvalidFormTypes')] + public function testInvalidFormType(string $type, string $message): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($message); + $this->resolveLaunchForm(['form' => ['input' => ['type' => $type]]]); + } + + /** + * @return iterable + */ + public static function provideInvalidFormTypes(): iterable + { + $unknown = static fn (string $type): string => \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" must be the FQCN of a form type, the class does not exist.', $type); + + // The short names of the form types are not supported + yield 'short name' => ['file', $unknown('file')]; + yield 'missing class' => ['App\Missing', $unknown('App\Missing')]; + yield 'not a form type' => [\stdClass::class, 'The type "stdClass" of the launch form field "form.input" of the process "test.process" must implement "Symfony\Component\Form\FormTypeInterface".']; + yield 'button class' => [SubmitType::class, \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is a button type, it holds no data.', SubmitType::class)]; + } + + public function testFieldConstraints(): void + { + $constraints = [['NotBlank' => null], ['Length' => ['max' => 10]]]; + [$form] = $this->resolveLaunchForm(['form' => [ + 'input' => ['options' => ['constraints' => $constraints]], + 'context' => ['foo' => ['options' => ['constraints' => $constraints]]], + ]]); + + foreach ([$form['input'], $form['context']['foo']] as $field) { + $fieldConstraints = $field['options']['constraints']; + self::assertIsArray($fieldConstraints); + self::assertCount(2, $fieldConstraints); + self::assertInstanceOf(NotBlank::class, $fieldConstraints[0]); + self::assertInstanceOf(Length::class, $fieldConstraints[1]); + self::assertSame(10, $fieldConstraints[1]->max); + } + } + + /** + * A project-specific constraint, given by its FQCN. + */ + public function testFieldConstraintClass(): void + { + [$form] = $this->resolveLaunchForm(['form' => ['context' => ['count' => ['options' => ['constraints' => [ + [Even::class => null], + [Even::class => ['message' => 'Odd value.']], + ]]]]]]); + + $constraints = $form['context']['count']['options']['constraints']; + self::assertIsArray($constraints); + self::assertCount(2, $constraints); + self::assertInstanceOf(Even::class, $constraints[0]); + self::assertInstanceOf(Even::class, $constraints[1]); + self::assertSame('Odd value.', $constraints[1]->message); + } + + public function testFieldConstraintsMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "constraints" form option of the launch form field "form.context.foo" of the process "test.process" must be an array of constraints.'); + $this->resolveLaunchForm(['form' => ['context' => ['foo' => ['options' => ['constraints' => 'NotBlank']]]]]); + } + + #[TestWith(['data'])] + #[TestWith(['mapped'])] + #[TestWith(['property_path'])] + #[TestWith(['inherit_data'])] + public function testUnsupportedFieldOption(string $option): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The "%s" form option of the launch form field "form.input" of the process "test.process" is not supported, use "default" for the initial value.', $option)); + $this->resolveLaunchForm(['form' => ['input' => ['options' => [$option => 'foo']]]]); + } + + public function testContextFieldWithoutSpec(): void + { + [$form] = $this->resolveLaunchForm(['form' => ['context' => ['comment' => null]]]); + + self::assertSame(['comment' => ['type' => TextType::class, 'options' => [], 'default' => null]], $form['context']); + } + + public function testContextFieldWithAnUnknownKey(): void + { + $this->expectException(UndefinedOptionsException::class); + $this->expectExceptionMessage('The option "label" does not exist.'); + $this->resolveLaunchForm(['form' => ['context' => ['comment' => ['label' => 'Comment']]]]); + } + + public function testContextFieldIsNotAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The launch form field "form.context.comment" of the process "test.process" must be an array or null, "string" given.'); + $this->resolveLaunchForm(['form' => ['context' => ['comment' => 'text']]]); + } + + #[TestWith(['my key'])] + #[TestWith([''])] + #[TestWith(['-a'])] + #[TestWith([':a'])] + #[TestWith(['a.b'])] + #[TestWith(['a[b]'])] + public function testInvalidContextKey(int|string $key): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key)); + $this->resolveLaunchForm(['form' => ['context' => [$key => null]]]); + } + + /** + * A purely numeric key is cast to an integer by PHP, even quoted in YAML. + */ + #[TestWith([0])] + #[TestWith([123])] + public function testNumericContextKey(int $key): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%d" of "form.context" of the process "test.process" is not supported: purely numeric context keys are cast to integers by PHP.', $key)); + $this->resolveLaunchForm(['form' => ['context' => [(string) $key => null]]]); + } + + #[TestWith(['a-b:c'])] + #[TestWith(['_x'])] + #[TestWith(['1a'])] + #[TestWith(['A'])] + public function testValidContextKey(string $key): void + { + [$form] = $this->resolveLaunchForm(['form' => ['context' => [$key => null]]]); + + self::assertSame([$key], array_keys($form['context'])); + } + + /** + * The key is reserved whether the free context is allowed or not: "extra" is the child of the free context pairs. + */ + #[TestWith(['extra', true])] + #[TestWith(['extra', false])] + public function testReservedContextKey(string $key, bool $allowExtraContext): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is reserved.', $key)); + $this->resolveLaunchForm(['form' => ['allow_extra_context' => $allowExtraContext, 'context' => [$key => null]]]); + } + + /** + * @param array $form + */ + #[DataProvider('provideInvalidFileFields')] + public function testInvalidFileField(array $form, string $message): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($message); + $this->resolveLaunchForm(['form' => $form]); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidFileFields(): iterable + { + yield 'file in the context' => [ + ['context' => ['data' => ['type' => FileType::class]]], + 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', + ]; + yield 'file with a default value' => [ + ['input' => ['type' => FileType::class, 'default' => 'data.csv']], + 'The launch form field "form.input" of the process "test.process" is a file field, it cannot have a default value.', + ]; + yield 'multiple files' => [ + ['input' => ['type' => FileType::class, 'options' => ['multiple' => true]]], + 'The launch form field "form.input" of the process "test.process" cannot upload multiple files.', + ]; + } + + /** + * @param class-string<\Throwable> $exception + */ + #[DataProvider('provideInvalidForms')] + public function testInvalidForm(mixed $form, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + $this->resolveLaunchForm(['form' => $form]); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidForms(): iterable + { + yield 'not an array' => ['foo', InvalidOptionsException::class, 'The "form" UI option of the process "test.process" must be an array, "string" given.']; + yield 'unknown key' => [['fields' => []], UndefinedOptionsException::class, 'The option "fields" does not exist.']; + yield 'allow_extra_context not a boolean' => [ + ['allow_extra_context' => 'yes'], + InvalidOptionsException::class, + 'The option "allow_extra_context" with value "yes" is expected to be of type "bool"', + ]; + } + + public function testFormConstraints(): void + { + [$form, $deprecations] = $this->resolveLaunchForm(['form' => ['constraints' => [['NotBlank' => null]]]]); + + self::assertSame([], $deprecations); + self::assertCount(1, $form['constraints']); + self::assertInstanceOf(NotBlank::class, $form['constraints'][0]); + } + + public function testDeprecatedConstraintsAreEffective(): void + { + [$form, $deprecations] = $this->resolveLaunchForm(['constraints' => [['NotBlank' => null]]]); + + self::assertSame([self::deprecation('constraints', 'form.constraints')], $deprecations); + self::assertCount(1, $form['constraints']); + self::assertInstanceOf(NotBlank::class, $form['constraints'][0]); + } + + public function testConstraintsConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "constraints" and "form.constraints" UI options of the process "test.process" cannot be used together, use "form.constraints" only.'); + $this->resolveLaunchForm(['constraints' => [], 'form' => ['constraints' => []]]); + } + + public function testEntrypointTypeConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.'); + $this->resolveLaunchForm(['entrypoint_type' => 'text', 'form' => ['input' => ['type' => TextType::class]]]); + } + + /** + * A null "form.input" is not given: no conflict with "entrypoint_type". + */ + public function testEntrypointTypeWithANullFormInput(): void + { + [$form, $deprecations] = $this->resolveLaunchForm(['entrypoint_type' => 'file', 'form' => ['input' => null, 'context' => ['delimiter' => null]]]); + + self::assertSame([self::deprecation('entrypoint_type', 'form.input.type')], $deprecations); + self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $form['input']); + self::assertSame(['delimiter'], array_keys($form['context'])); + } + + /** + * The conflict depends on the presence of the options, not on their values. + */ + public function testNullInputDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); + $this->resolveLaunchForm(['form' => ['input' => ['default' => null]], 'default' => ['input' => null]]); + } + + /** + * @param array $ui + * @param array $input + */ + #[DataProvider('provideDeprecatedInputs')] + public function testDeprecatedInputIsFolded(array $ui, array $input): void + { + [$form] = $this->resolveLaunchForm($ui); + + self::assertSame($input, $form['input']); + } + + /** + * @return iterable, array}> + */ + public static function provideDeprecatedInputs(): iterable + { + yield 'file entrypoint' => [['entrypoint_type' => 'file'], ['type' => FileType::class, 'options' => [], 'default' => null]]; + yield 'text entrypoint with a default input' => [ + ['entrypoint_type' => 'text', 'default' => ['input' => 'data.csv']], + ['type' => TextType::class, 'options' => [], 'default' => 'data.csv'], + ]; + yield 'file entrypoint with a default input' => [ + ['entrypoint_type' => 'file', 'default' => ['input' => 'data.csv']], + ['type' => FileType::class, 'options' => [], 'default' => null], + ]; + yield 'declared input without default' => [ + ['form' => ['input' => ['type' => TextareaType::class]], 'default' => ['input' => 'data.csv']], + ['type' => TextareaType::class, 'options' => [], 'default' => 'data.csv'], + ]; + yield 'declared file input without default' => [ + ['form' => ['input' => ['type' => FileType::class]], 'default' => ['input' => 'data.csv']], + ['type' => FileType::class, 'options' => [], 'default' => null], + ]; + } + + public function testDeprecatedContextDefaultIsFolded(): void + { + [$form] = $this->resolveLaunchForm([ + 'form' => ['context' => ['delimiter' => ['type' => TextType::class], 'comment' => null]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ','], ['key' => 'delimiter', 'value' => ';']]], + ]); + + self::assertSame(';', $form['context']['delimiter']['default']); + self::assertNull($form['context']['comment']['default']); + } + + public function testContextDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the context key "delimiter" of the process "test.process" is set both in "default.context" and in "form.context.delimiter.default", use "form.context.delimiter.default" only.'); + $this->resolveLaunchForm([ + 'form' => ['context' => ['delimiter' => ['default' => null]]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], + ]); + } + + public function testInputDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); + $this->resolveLaunchForm(['form' => ['input' => ['default' => 'a.csv']], 'default' => ['input' => 'b.csv']]); + } + + public function testExtraContextKeyNotAllowed(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The context key "foo" of "default.context" of the process "test.process" is not declared in "form.context", and "form.allow_extra_context" is false.'); + $this->resolveLaunchForm([ + 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], + 'default' => ['context' => [['key' => 'foo', 'value' => 'bar']]], + ]); + } + + public function testDeclaredContextKeyWithoutExtraContext(): void + { + [$form] = $this->resolveLaunchForm([ + 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], + ]); + + self::assertFalse($form['allow_extra_context']); + self::assertSame(',', $form['context']['delimiter']['default']); + } + + /** + * @param array $ui + */ + #[DataProvider('provideDeprecatedOptions')] + public function testDeprecatedOption(array $ui, string $deprecation): void + { + [, $deprecations] = $this->resolveLaunchForm($ui); + + self::assertSame([$deprecation], $deprecations); + } + + /** + * @return iterable, string}> + */ + public static function provideDeprecatedOptions(): iterable + { + yield 'entrypoint_type' => [['entrypoint_type' => 'text'], self::deprecation('entrypoint_type', 'form.input.type')]; + yield 'default' => [['default' => []], self::deprecation('default', 'form.input.default" and "form.context..default')]; + yield 'constraints' => [['constraints' => []], self::deprecation('constraints', 'form.constraints')]; + } + + public function testNewFormatIsNotDeprecated(): void + { + [, $deprecations] = $this->resolveLaunchForm([ + 'ui_launch_mode' => 'form', + 'form' => [ + 'input' => ['type' => FileType::class, 'options' => ['constraints' => [['NotNull' => null]]]], + 'context' => ['delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'allow_extra_context' => false, + 'constraints' => [], + ], + ]); + + self::assertSame([], $deprecations); + } + + public function testLaunchFormOfAnUnknownProcess(): void + { + self::assertNull($this->createManager(['test.process' => $this->rawProcess()])->getLaunchFormOptions('unknown')); + } + + /** + * @param array $ui + * @param array $data + */ + #[DataProvider('provideLaunchFormData')] + public function testLaunchFormData(array $ui, array $data): void + { + [$form] = $this->resolveLaunchForm($ui); + + self::assertSame($data, $form['data']); + } + + /** + * @return iterable, array}> + */ + public static function provideLaunchFormData(): iterable + { + yield 'new format' => [ + ['form' => [ + 'input' => ['default' => 'data.csv'], + 'context' => ['delimiter' => ['default' => ','], 'comment' => null, 'dry_run' => ['type' => CheckboxType::class, 'default' => false]], + ]], + ['input' => 'data.csv', 'context' => ['delimiter' => ',', 'comment' => null, 'dry_run' => false]], + ]; + yield 'deprecated format' => [ + ['default' => ['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar']]]], + ['input' => 'data.csv', 'context' => ['foo' => 'bar']], + ]; + yield 'mixed format' => [ + [ + 'form' => ['context' => ['delimiter' => null, 'comment' => ['default' => 'none']]], + 'default' => ['context' => [['key' => 'foo', 'value' => 'bar'], ['key' => 'delimiter', 'value' => ';']]], + ], + ['input' => null, 'context' => ['delimiter' => ';', 'comment' => 'none', 'foo' => 'bar']], + ]; + yield 'file input' => [['form' => ['input' => ['type' => FileType::class]]], ['input' => null, 'context' => []]]; + // The last row of a key wins, the free keys keep the order of their first row + yield 'duplicated free keys' => [ + ['default' => ['context' => [['key' => 'foo', 'value' => 'a'], ['key' => 'bar', 'value' => 'b'], ['key' => 'foo', 'value' => 'c']]]], + ['input' => null, 'context' => ['foo' => 'c', 'bar' => 'b']], + ]; + // The declared keys first, in their order of declaration, whatever the order of the deprecated rows + yield 'declared keys first' => [ + [ + 'form' => ['context' => ['b' => null, 'a' => ['default' => 'A']]], + 'default' => ['context' => [['key' => 'free', 'value' => 'F'], ['key' => 'b', 'value' => 'B']]], + ], + ['input' => null, 'context' => ['b' => 'B', 'a' => 'A', 'free' => 'F']], + ]; + } + + /** + * The duplicated rows of "default.context" are kept in the resolved "default" option (backward compatibility), + * the last one wins in the launch form. + */ + public function testDuplicatedDefaultContextKeys(): void + { + $rows = [['key' => 'delimiter', 'value' => ','], ['key' => 'foo', 'value' => 'a'], ['key' => 'foo', 'value' => 'b'], ['key' => 'delimiter', 'value' => ';']]; + $ui = ['form' => ['context' => ['delimiter' => null]], 'default' => ['context' => $rows]]; + [$form] = $this->resolveLaunchForm($ui); + + $uiOptions = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])])->getUiOptions('test.process'); + self::assertSame($rows, $uiOptions['default']['context'] ?? null); + self::assertSame(';', $form['context']['delimiter']['default']); + self::assertSame(['delimiter' => ';', 'foo' => 'b'], $form['data']['context']); + } + + public function testDeprecationsAreTriggeredOncePerResolution(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'text']])]); + + $deprecations = $this->collectDeprecations(static function () use ($manager): void { + $manager->getLaunchFormOptions('test.process'); + $manager->getLaunchFormOptions('test.process'); + }); + + $expected = self::deprecation('entrypoint_type', 'form.input.type'); + self::assertSame([$expected, $expected], $deprecations); + } + + /** + * @param array $ui + * + * @return array{LaunchFormOptions, list} + */ + private function resolveLaunchForm(array $ui): array + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); + + $form = null; + $deprecations = $this->collectDeprecations(static function () use ($manager, &$form): void { + $form = $manager->getLaunchFormOptions('test.process'); + }); + self::assertIsArray($form); + // Every resolution checks its deprecations: exactly one per deprecated option given + self::assertSame(self::expectedDeprecations($ui), $deprecations); + + return [$form, $deprecations]; + } + + /** + * The deprecations triggered by the resolution of the given UI options, in their order of resolution. + * + * @param array $ui + * + * @return list + */ + private static function expectedDeprecations(array $ui): array + { + $replacements = [ + 'entrypoint_type' => 'form.input.type', + 'constraints' => 'form.constraints', + 'default' => 'form.input.default" and "form.context..default', + ]; + $deprecations = []; + foreach ($replacements as $option => $replacement) { + if (\array_key_exists($option, $ui)) { + $deprecations[] = self::deprecation($option, $replacement); + } + } + + return $deprecations; + } + + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private static function deprecation(string $option, string $replacement): string + { + return \sprintf('Since cleverage/ui-process-bundle 3.3: The "%s" UI option of the process "test.process" is deprecated, use "%s" instead.', $option, $replacement); + } + + /** + * @param array> $rawConfiguration + */ + private function createManager(array $rawConfiguration): ProcessConfigurationsManager + { + return new ProcessConfigurationsManager(new ProcessConfigurationRegistry($rawConfiguration, 'stop')); + } + + /** + * Raw configuration of a process with a single task, as the process bundle configuration would give it. + * + * @param array $options + * + * @return array + */ + private function rawProcess(array $options = [], bool $public = true): array + { + return [ + 'options' => $options, + 'entry_point' => null, + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => $public, + 'tasks' => [ + 'data' => [ + 'service' => '@CleverAge\ProcessBundle\Task\DummyTask', + 'options' => [], + 'description' => '', + 'help' => '', + 'outputs' => [], + 'errors' => [], + 'error_outputs' => [], + 'error_strategy' => null, + 'log_level' => null, + ], + ], + ]; + } +} diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php index 057a3e2..461ac4c 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -14,6 +14,7 @@ namespace CleverAge\UiProcessBundle\Tests\Form\Type; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; @@ -40,6 +41,7 @@ #[CoversClass(LaunchType::class)] #[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(ProcessContextType::class)] class LaunchTypeTest extends TypeTestCase { @@ -300,21 +302,21 @@ public function testRequiredOptionOfTheInput(): void } /** - * The UI options already resolved by the caller are not resolved again. + * The launch form already resolved by the caller is not resolved again. */ - public function testResolvedUiOptions(): void + public function testResolvedLaunchForm(): void { $registry = new ProcessConfigurationRegistry(['file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data')], 'stop'); - $uiOptions = null; - $resolutionDeprecations = $this->collectDeprecations(static function () use ($registry, &$uiOptions): void { - $uiOptions = (new ProcessConfigurationsManager($registry))->getUiOptions('file.process'); + $launchForm = null; + $resolutionDeprecations = $this->collectDeprecations(static function () use ($registry, &$launchForm): void { + $launchForm = (new ProcessConfigurationsManager($registry))->getLaunchFormOptions('file.process'); }); self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "file.process" is deprecated, use "form.input.type" instead.'], $resolutionDeprecations); - self::assertIsArray($uiOptions); + self::assertIsArray($launchForm); $form = null; - $deprecations = $this->collectDeprecations(function () use (&$form, $uiOptions): void { - $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process', 'ui_options' => $uiOptions]); + $deprecations = $this->collectDeprecations(function () use (&$form, $launchForm): void { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process', 'launch_form' => $launchForm]); }); self::assertSame([], $deprecations); @@ -322,10 +324,10 @@ public function testResolvedUiOptions(): void self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); } - public function testUiOptionsMustBeAnArray(): void + public function testLaunchFormMustBeAnArray(): void { $this->expectException(InvalidOptionsException::class); - $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process', 'ui_options' => 'form']); + $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process', 'launch_form' => 'form']); } public function testProcessCodeIsRequired(): void diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index 210fc59..ae526e4 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -111,4 +111,26 @@ protected function getGeneratedToken(string $flashMessage): string return $matches[1]; } + + /** + * The user deprecations triggered by the callback. + * + * @return list + */ + protected function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 4a5e4e6..9801883 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -27,6 +27,7 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\ContextValueNormalizer; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; @@ -56,6 +57,7 @@ #[UsesClass(LaunchType::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(ContextValueNormalizer::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(ProcessExecuteMessage::class)] #[UsesClass(ProcessExecutionRepository::class)] @@ -599,26 +601,6 @@ public function testDirectAccessRedirectsToTheDashboard(): void /** * The error message displayed under the form field with the given id. */ - /** - * @return list - */ - private function collectDeprecations(\Closure $callback): array - { - $deprecations = []; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { - $deprecations[] = $errstr; - - return true; - }, \E_USER_DEPRECATED); - try { - $callback(); - } finally { - restore_error_handler(); - } - - return $deprecations; - } - private function getFieldError(Crawler $crawler, string $id): string { $errors = $crawler->filter('#'.$id)->closest('.form-widget')?->filter('.invalid-feedback'); diff --git a/tests/Functional/ProcessListTest.php b/tests/Functional/ProcessListTest.php index 04bacbe..2cda30d 100644 --- a/tests/Functional/ProcessListTest.php +++ b/tests/Functional/ProcessListTest.php @@ -102,6 +102,20 @@ public function testDeprecatedRouteName(): void self::assertStringContainsString('test.process', $crawler->filter('table')->text()); } + /** + * The launch form is only resolved by the launch page: the deprecated launch form options of the listed processes + * (e.g. "test.form") trigger no deprecation on the list. + */ + public function testListDoesNotResolveTheLaunchForms(): void + { + $this->login(); + + $deprecations = $this->collectDeprecations(fn () => $this->client->request('GET', '/process?routeName=cleverage_ui_process_list')); + + self::assertResponseIsSuccessful(); + self::assertSame([], $deprecations); + } + public function testUsersMenuIsReservedToAdmins(): void { $this->login(); diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index bb31398..e5bc589 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -14,23 +14,13 @@ namespace CleverAge\UiProcessBundle\Tests\Manager; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; -use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; -use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; -use CleverAge\UiProcessBundle\Tests\App\Validator\Even; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\FileType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface as OptionsResolverException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; @@ -38,9 +28,6 @@ use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; -/** - * @phpstan-import-type UiOptions from ProcessConfigurationsManager - */ #[CoversClass(ProcessConfigurationsManager::class)] #[UsesClass(NotificationTrigger::class)] class ProcessConfigurationsManagerTest extends TestCase @@ -73,9 +60,9 @@ public function testUiOptionsOfAnUnknownProcess(): void public function testDefaultUiOptions(): void { - [$options, $deprecations] = $this->resolveUi([]); + $options = $this->createManager(['test.process' => $this->rawProcess()])->getUiOptions('test.process'); - self::assertSame([], $deprecations); + self::assertNotNull($options); self::assertNull($options['source']); self::assertNull($options['target']); self::assertSame('text', $options['entrypoint_type']); @@ -85,40 +72,62 @@ public function testDefaultUiOptions(): void $run = $options['run']; self::assertNull($run); self::assertSame(['input' => null, 'context' => []], $options['default']); - self::assertSame( - [ - 'input' => ['type' => TextType::class, 'options' => [], 'default' => null], - 'context' => [], - 'allow_extra_context' => true, - 'constraints' => [], - ], - $options['form'] - ); + self::assertSame([], $options['form']); + } + + /** + * The launch form is resolved by getLaunchFormOptions() only: an invalid "form" option does not break the other + * pages (process list, executions), and the deprecations of the launch form are not triggered. + */ + public function testLaunchFormIsNotResolved(): void + { + $form = ['input' => ['type' => 'App\\Missing'], 'context' => ['a b' => 'text']]; + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => [ + 'source' => 'ERP', + 'entrypoint_type' => 'text', + 'form' => $form, + ]])]); + + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $options = $manager->getUiOptions('test.process'); + } finally { + restore_error_handler(); + } + + self::assertSame([], $deprecations); + self::assertNotNull($options); + self::assertSame('ERP', $options['source']); + self::assertSame($form, $options['form']); } public function testConfiguredUiOptions(): void { - [$options, $deprecations] = $this->resolveUi([ - 'source' => 'source', - 'target' => 'target', - 'entrypoint_type' => 'file', - 'ui_launch_mode' => 'form', - 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], - 'run' => true, - 'default' => [ - 'input' => 'data.csv', - 'context' => [['key' => 'foo', 'value' => 'bar']], - ], + $manager = $this->createManager([ + 'test.process' => $this->rawProcess([ + 'ui' => [ + 'source' => 'source', + 'target' => 'target', + 'entrypoint_type' => 'file', + 'ui_launch_mode' => 'form', + 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], + 'run' => true, + 'default' => [ + 'input' => 'data.csv', + 'context' => [['key' => 'foo', 'value' => 'bar']], + ], + ], + ]), ]); - self::assertSame( - [ - self::deprecation('entrypoint_type', 'form.input.type'), - self::deprecation('constraints', 'form.constraints'), - self::deprecation('default', 'form.input.default" and "form.context..default'), - ], - $deprecations - ); + $options = $manager->getUiOptions('test.process'); + + self::assertNotNull($options); self::assertSame('source', $options['source']); self::assertSame('target', $options['target']); self::assertSame('file', $options['entrypoint_type']); @@ -131,18 +140,17 @@ public function testConfiguredUiOptions(): void self::assertInstanceOf(NotBlank::class, $options['constraints'][0]); self::assertInstanceOf(Length::class, $options['constraints'][1]); self::assertSame(10, $options['constraints'][1]->max); - // The deprecated options are folded into the effective "form" option - self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $options['form']['input']); - self::assertSame([], $options['form']['context']); - self::assertTrue($options['form']['allow_extra_context']); - self::assertSame($options['constraints'], $options['form']['constraints']); } public function testDefaultWithoutContext(): void { - [$options, $deprecations] = $this->resolveUi(['default' => ['input' => 'data.csv']]); + $manager = $this->createManager([ + 'test.process' => $this->rawProcess(['ui' => ['default' => ['input' => 'data.csv']]]), + ]); - self::assertSame([self::deprecation('default', 'form.input.default" and "form.context..default')], $deprecations); + $options = $manager->getUiOptions('test.process'); + + self::assertNotNull($options); self::assertSame(['input' => 'data.csv', 'context' => []], $options['default']); } @@ -153,8 +161,10 @@ public function testDefaultWithoutContext(): void #[DataProvider('provideInvalidDefault')] public function testInvalidDefault(array $default, string $exception): void { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => $default]])]); + $this->expectException($exception); - $this->resolveUi(['default' => $default]); + $manager->getUiOptions('test.process'); } /** @@ -174,508 +184,18 @@ public static function provideInvalidDefault(): iterable public function testDefaultIsNotAnArray(): void { - $this->expectException(InvalidOptionsException::class); - $this->resolveUi(['default' => 'data.csv']); - } - - public function testInvalidEntrypointType(): void - { - $this->expectException(InvalidOptionsException::class); - $this->resolveUi(['entrypoint_type' => 'csv']); - } - - public function testFormInput(): void - { - [$options, $deprecations] = $this->resolveUi(['form' => ['input' => ['type' => FileType::class, 'options' => ['label' => 'CSV file']]]]); - - self::assertSame([], $deprecations); - self::assertSame(['type' => FileType::class, 'options' => ['label' => 'CSV file'], 'default' => null], $options['form']['input']); - } - - /** - * @param class-string $class - */ - #[TestWith([ChoiceType::class, ChoiceType::class])] - #[TestWith([ProcessContextType::class, ProcessContextType::class])] - #[TestWith(['\\'.TextType::class, TextType::class])] - public function testFormTypeClass(string $type, string $class): void - { - [$options] = $this->resolveUi(['form' => ['input' => ['type' => $type], 'context' => ['foo' => ['type' => $type]]]]); - - self::assertSame($class, $options['form']['input']['type']); - self::assertSame($class, $options['form']['context']['foo']['type']); - } - - #[DataProvider('provideInvalidFormTypes')] - public function testInvalidFormType(string $type, string $message): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage($message); - $this->resolveUi(['form' => ['input' => ['type' => $type]]]); - } - - /** - * @return iterable - */ - public static function provideInvalidFormTypes(): iterable - { - $unknown = static fn (string $type): string => \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" must be the FQCN of a form type, the class does not exist.', $type); - - // The short names of the form types are not supported - yield 'short name' => ['file', $unknown('file')]; - yield 'missing class' => ['App\Missing', $unknown('App\Missing')]; - yield 'not a form type' => [\stdClass::class, 'The type "stdClass" of the launch form field "form.input" of the process "test.process" must implement "Symfony\Component\Form\FormTypeInterface".']; - yield 'button class' => [SubmitType::class, \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is a button type, it holds no data.', SubmitType::class)]; - } - - public function testFieldConstraints(): void - { - $constraints = [['NotBlank' => null], ['Length' => ['max' => 10]]]; - [$options] = $this->resolveUi(['form' => [ - 'input' => ['options' => ['constraints' => $constraints]], - 'context' => ['foo' => ['options' => ['constraints' => $constraints]]], - ]]); - - foreach ([$options['form']['input'], $options['form']['context']['foo']] as $field) { - $fieldConstraints = $field['options']['constraints']; - self::assertIsArray($fieldConstraints); - self::assertCount(2, $fieldConstraints); - self::assertInstanceOf(NotBlank::class, $fieldConstraints[0]); - self::assertInstanceOf(Length::class, $fieldConstraints[1]); - self::assertSame(10, $fieldConstraints[1]->max); - } - } - - /** - * A project-specific constraint, given by its FQCN. - */ - public function testFieldConstraintClass(): void - { - [$options] = $this->resolveUi(['form' => ['context' => ['count' => ['options' => ['constraints' => [ - [Even::class => null], - [Even::class => ['message' => 'Odd value.']], - ]]]]]]); - - $constraints = $options['form']['context']['count']['options']['constraints']; - self::assertIsArray($constraints); - self::assertCount(2, $constraints); - self::assertInstanceOf(Even::class, $constraints[0]); - self::assertInstanceOf(Even::class, $constraints[1]); - self::assertSame('Odd value.', $constraints[1]->message); - } - - public function testFieldConstraintsMustBeAnArray(): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The "constraints" form option of the launch form field "form.context.foo" of the process "test.process" must be an array of constraints.'); - $this->resolveUi(['form' => ['context' => ['foo' => ['options' => ['constraints' => 'NotBlank']]]]]); - } - - #[TestWith(['data'])] - #[TestWith(['mapped'])] - #[TestWith(['property_path'])] - #[TestWith(['inherit_data'])] - public function testUnsupportedFieldOption(string $option): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage(\sprintf('The "%s" form option of the launch form field "form.input" of the process "test.process" is not supported, use "default" for the initial value.', $option)); - $this->resolveUi(['form' => ['input' => ['options' => [$option => 'foo']]]]); - } - - public function testContextFieldWithoutSpec(): void - { - [$options] = $this->resolveUi(['form' => ['context' => ['comment' => null]]]); - - self::assertSame(['comment' => ['type' => TextType::class, 'options' => [], 'default' => null]], $options['form']['context']); - } - - public function testContextFieldWithAnUnknownKey(): void - { - $this->expectException(UndefinedOptionsException::class); - $this->expectExceptionMessage('The option "label" does not exist.'); - $this->resolveUi(['form' => ['context' => ['comment' => ['label' => 'Comment']]]]); - } - - public function testContextFieldIsNotAnArray(): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The launch form field "form.context.comment" of the process "test.process" must be an array or null, "string" given.'); - $this->resolveUi(['form' => ['context' => ['comment' => 'text']]]); - } - - #[TestWith(['my key'])] - #[TestWith([''])] - #[TestWith(['-a'])] - #[TestWith([':a'])] - #[TestWith(['a.b'])] - #[TestWith(['a[b]'])] - public function testInvalidContextKey(int|string $key): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key)); - $this->resolveUi(['form' => ['context' => [$key => null]]]); - } - - /** - * A purely numeric key is cast to an integer by PHP, even quoted in YAML. - */ - #[TestWith([0])] - #[TestWith([123])] - public function testNumericContextKey(int $key): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage(\sprintf('The context key "%d" of "form.context" of the process "test.process" is not supported: purely numeric context keys are cast to integers by PHP.', $key)); - $this->resolveUi(['form' => ['context' => [(string) $key => null]]]); - } - - #[TestWith(['a-b:c'])] - #[TestWith(['_x'])] - #[TestWith(['1a'])] - #[TestWith(['A'])] - public function testValidContextKey(string $key): void - { - [$options] = $this->resolveUi(['form' => ['context' => [$key => null]]]); - - self::assertSame([$key], array_keys($options['form']['context'])); - } - - /** - * The key is reserved whether the free context is allowed or not: "extra" is the child of the free context pairs. - */ - #[TestWith(['extra', true])] - #[TestWith(['extra', false])] - public function testReservedContextKey(string $key, bool $allowExtraContext): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is reserved.', $key)); - $this->resolveUi(['form' => ['allow_extra_context' => $allowExtraContext, 'context' => [$key => null]]]); - } - - /** - * @param array $form - */ - #[DataProvider('provideInvalidFileFields')] - public function testInvalidFileField(array $form, string $message): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage($message); - $this->resolveUi(['form' => $form]); - } - - /** - * @return iterable, string}> - */ - public static function provideInvalidFileFields(): iterable - { - yield 'file in the context' => [ - ['context' => ['data' => ['type' => FileType::class]]], - 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', - ]; - yield 'file with a default value' => [ - ['input' => ['type' => FileType::class, 'default' => 'data.csv']], - 'The launch form field "form.input" of the process "test.process" is a file field, it cannot have a default value.', - ]; - yield 'multiple files' => [ - ['input' => ['type' => FileType::class, 'options' => ['multiple' => true]]], - 'The launch form field "form.input" of the process "test.process" cannot upload multiple files.', - ]; - } - - /** - * @param class-string<\Throwable> $exception - */ - #[DataProvider('provideInvalidForms')] - public function testInvalidForm(mixed $form, string $exception, string $message): void - { - $this->expectException($exception); - $this->expectExceptionMessage($message); - $this->resolveUi(['form' => $form]); - } - - /** - * @return iterable, string}> - */ - public static function provideInvalidForms(): iterable - { - yield 'not an array' => ['foo', InvalidOptionsException::class, 'The option "form" with value "foo" is expected to be of type "array"']; - yield 'unknown key' => [['fields' => []], UndefinedOptionsException::class, 'The option "fields" does not exist.']; - yield 'allow_extra_context not a boolean' => [ - ['allow_extra_context' => 'yes'], - InvalidOptionsException::class, - 'The option "allow_extra_context" with value "yes" is expected to be of type "bool"', - ]; - } - - public function testFormConstraints(): void - { - [$options, $deprecations] = $this->resolveUi(['form' => ['constraints' => [['NotBlank' => null]]]]); - - self::assertSame([], $deprecations); - self::assertCount(1, $options['form']['constraints']); - self::assertInstanceOf(NotBlank::class, $options['form']['constraints'][0]); - } - - public function testDeprecatedConstraintsAreEffective(): void - { - [$options, $deprecations] = $this->resolveUi(['constraints' => [['NotBlank' => null]]]); - - self::assertSame([self::deprecation('constraints', 'form.constraints')], $deprecations); - self::assertCount(1, $options['form']['constraints']); - self::assertInstanceOf(NotBlank::class, $options['form']['constraints'][0]); - } - - public function testConstraintsConflict(): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The "constraints" and "form.constraints" UI options of the process "test.process" cannot be used together, use "form.constraints" only.'); - $this->resolveUi(['constraints' => [], 'form' => ['constraints' => []]]); - } - - public function testEntrypointTypeConflict(): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.'); - $this->resolveUi(['entrypoint_type' => 'text', 'form' => ['input' => ['type' => TextType::class]]]); - } - - /** - * A null "form.input" is not given: no conflict with "entrypoint_type". - */ - public function testEntrypointTypeWithANullFormInput(): void - { - [$options, $deprecations] = $this->resolveUi(['entrypoint_type' => 'file', 'form' => ['input' => null, 'context' => ['delimiter' => null]]]); - - self::assertSame([self::deprecation('entrypoint_type', 'form.input.type')], $deprecations); - self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $options['form']['input']); - self::assertSame(['delimiter'], array_keys($options['form']['context'])); - } - - /** - * The conflict depends on the presence of the options, not on their values. - */ - public function testNullInputDefaultConflict(): void - { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); - $this->resolveUi(['form' => ['input' => ['default' => null]], 'default' => ['input' => null]]); - } - - /** - * @param array $ui - * @param array $input - */ - #[DataProvider('provideDeprecatedInputs')] - public function testDeprecatedInputIsFolded(array $ui, array $input): void - { - [$options] = $this->resolveUi($ui); - - self::assertSame($input, $options['form']['input']); - } - - /** - * @return iterable, array}> - */ - public static function provideDeprecatedInputs(): iterable - { - yield 'file entrypoint' => [['entrypoint_type' => 'file'], ['type' => FileType::class, 'options' => [], 'default' => null]]; - yield 'text entrypoint with a default input' => [ - ['entrypoint_type' => 'text', 'default' => ['input' => 'data.csv']], - ['type' => TextType::class, 'options' => [], 'default' => 'data.csv'], - ]; - yield 'file entrypoint with a default input' => [ - ['entrypoint_type' => 'file', 'default' => ['input' => 'data.csv']], - ['type' => FileType::class, 'options' => [], 'default' => null], - ]; - yield 'declared input without default' => [ - ['form' => ['input' => ['type' => TextareaType::class]], 'default' => ['input' => 'data.csv']], - ['type' => TextareaType::class, 'options' => [], 'default' => 'data.csv'], - ]; - yield 'declared file input without default' => [ - ['form' => ['input' => ['type' => FileType::class]], 'default' => ['input' => 'data.csv']], - ['type' => FileType::class, 'options' => [], 'default' => null], - ]; - } - - public function testDeprecatedContextDefaultIsFolded(): void - { - [$options] = $this->resolveUi([ - 'form' => ['context' => ['delimiter' => ['type' => TextType::class], 'comment' => null]], - 'default' => ['context' => [['key' => 'delimiter', 'value' => ','], ['key' => 'delimiter', 'value' => ';']]], - ]); - - self::assertSame(';', $options['form']['context']['delimiter']['default']); - self::assertNull($options['form']['context']['comment']['default']); - } + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => 'data.csv']])]); - public function testContextDefaultConflict(): void - { $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The default value of the context key "delimiter" of the process "test.process" is set both in "default.context" and in "form.context.delimiter.default", use "form.context.delimiter.default" only.'); - $this->resolveUi([ - 'form' => ['context' => ['delimiter' => ['default' => null]]], - 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], - ]); + $manager->getUiOptions('test.process'); } - public function testInputDefaultConflict(): void + public function testInvalidEntrypointType(): void { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); - $this->resolveUi(['form' => ['input' => ['default' => 'a.csv']], 'default' => ['input' => 'b.csv']]); - } + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'csv']])]); - public function testExtraContextKeyNotAllowed(): void - { $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage('The context key "foo" of "default.context" of the process "test.process" is not declared in "form.context", and "form.allow_extra_context" is false.'); - $this->resolveUi([ - 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], - 'default' => ['context' => [['key' => 'foo', 'value' => 'bar']]], - ]); - } - - public function testDeclaredContextKeyWithoutExtraContext(): void - { - [$options] = $this->resolveUi([ - 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], - 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], - ]); - - self::assertFalse($options['form']['allow_extra_context']); - self::assertSame(',', $options['form']['context']['delimiter']['default']); - } - - /** - * @param array $ui - */ - #[DataProvider('provideDeprecatedOptions')] - public function testDeprecatedOption(array $ui, string $deprecation): void - { - [, $deprecations] = $this->resolveUi($ui); - - self::assertSame([$deprecation], $deprecations); - } - - /** - * @return iterable, string}> - */ - public static function provideDeprecatedOptions(): iterable - { - yield 'entrypoint_type' => [['entrypoint_type' => 'text'], self::deprecation('entrypoint_type', 'form.input.type')]; - yield 'default' => [['default' => []], self::deprecation('default', 'form.input.default" and "form.context..default')]; - yield 'constraints' => [['constraints' => []], self::deprecation('constraints', 'form.constraints')]; - } - - public function testNewFormatIsNotDeprecated(): void - { - [, $deprecations] = $this->resolveUi([ - 'ui_launch_mode' => 'form', - 'form' => [ - 'input' => ['type' => FileType::class, 'options' => ['constraints' => [['NotNull' => null]]]], - 'context' => ['delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',']]]], - 'allow_extra_context' => false, - 'constraints' => [], - ], - ]); - - self::assertSame([], $deprecations); - } - - public function testLaunchFormDefaultsOfAnUnknownProcess(): void - { - $this->expectException(MissingProcessException::class); - $this->createManager(['test.process' => $this->rawProcess()])->getLaunchFormDefaults('unknown'); - } - - /** - * @param array $ui - * @param array $defaults - */ - #[DataProvider('provideLaunchFormDefaults')] - public function testLaunchFormDefaults(array $ui, array $defaults): void - { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); - - $actual = null; - $deprecations = $this->collectDeprecations(static function () use ($manager, &$actual): void { - $actual = $manager->getLaunchFormDefaults('test.process'); - }); - - self::assertSame($defaults, $actual); - self::assertSame(self::expectedDeprecations($ui), $deprecations); - - // From the UI options already resolved, without resolving them again - [$uiOptions] = $this->resolveUi($ui); - $fromUiOptions = null; - self::assertSame([], $this->collectDeprecations(static function () use ($manager, $uiOptions, &$fromUiOptions): void { - $fromUiOptions = $manager->buildLaunchFormDefaults($uiOptions); - })); - self::assertSame($defaults, $fromUiOptions); - } - - /** - * @return iterable, array}> - */ - public static function provideLaunchFormDefaults(): iterable - { - yield 'new format' => [ - ['form' => [ - 'input' => ['default' => 'data.csv'], - 'context' => ['delimiter' => ['default' => ','], 'comment' => null, 'dry_run' => ['type' => CheckboxType::class, 'default' => false]], - ]], - ['input' => 'data.csv', 'context' => ['delimiter' => ',', 'comment' => null, 'dry_run' => false]], - ]; - yield 'deprecated format' => [ - ['default' => ['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar']]]], - ['input' => 'data.csv', 'context' => ['foo' => 'bar']], - ]; - yield 'mixed format' => [ - [ - 'form' => ['context' => ['delimiter' => null, 'comment' => ['default' => 'none']]], - 'default' => ['context' => [['key' => 'foo', 'value' => 'bar'], ['key' => 'delimiter', 'value' => ';']]], - ], - ['input' => null, 'context' => ['delimiter' => ';', 'comment' => 'none', 'foo' => 'bar']], - ]; - yield 'file input' => [['form' => ['input' => ['type' => FileType::class]]], ['input' => null, 'context' => []]]; - // The last row of a key wins, the free keys keep the order of their first row - yield 'duplicated free keys' => [ - ['default' => ['context' => [['key' => 'foo', 'value' => 'a'], ['key' => 'bar', 'value' => 'b'], ['key' => 'foo', 'value' => 'c']]]], - ['input' => null, 'context' => ['foo' => 'c', 'bar' => 'b']], - ]; - // The declared keys first, in their order of declaration, whatever the order of the deprecated rows - yield 'declared keys first' => [ - [ - 'form' => ['context' => ['b' => null, 'a' => ['default' => 'A']]], - 'default' => ['context' => [['key' => 'free', 'value' => 'F'], ['key' => 'b', 'value' => 'B']]], - ], - ['input' => null, 'context' => ['b' => 'B', 'a' => 'A', 'free' => 'F']], - ]; - } - - /** - * The duplicated rows of "default.context" are kept in the resolved "default" option (backward compatibility), - * the last one wins in the effective "form" option. - */ - public function testDuplicatedDefaultContextKeys(): void - { - $rows = [['key' => 'delimiter', 'value' => ','], ['key' => 'foo', 'value' => 'a'], ['key' => 'foo', 'value' => 'b'], ['key' => 'delimiter', 'value' => ';']]; - [$options] = $this->resolveUi(['form' => ['context' => ['delimiter' => null]], 'default' => ['context' => $rows]]); - - self::assertSame($rows, $options['default']['context']); - self::assertSame(';', $options['form']['context']['delimiter']['default']); - } - - public function testDeprecationsAreTriggeredOncePerResolution(): void - { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'text']])]); - - $deprecations = $this->collectDeprecations(static function () use ($manager): void { - $manager->getUiOptions('test.process'); - $manager->getUiOptions('test.process'); - }); - - $expected = self::deprecation('entrypoint_type', 'form.input.type'); - self::assertSame([$expected, $expected], $deprecations); + $manager->getUiOptions('test.process'); } public function testInvalidLaunchMode(): void @@ -803,75 +323,6 @@ public function testInvalidLogsOptions(array $options, string $exception): void $manager->getLogsOptions('test.process'); } - /** - * @param array $ui - * - * @return array{0: UiOptions, 1: list} - */ - private function resolveUi(array $ui): array - { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); - - $options = null; - $deprecations = $this->collectDeprecations(static function () use ($manager, &$options): void { - $options = $manager->getUiOptions('test.process'); - }); - self::assertIsArray($options); - // Every resolution checks its deprecations: exactly one per deprecated option given - self::assertSame(self::expectedDeprecations($ui), $deprecations); - - return [$options, $deprecations]; - } - - /** - * The deprecations triggered by the resolution of the given UI options, in their order of resolution. - * - * @param array $ui - * - * @return list - */ - private static function expectedDeprecations(array $ui): array - { - $replacements = [ - 'entrypoint_type' => 'form.input.type', - 'constraints' => 'form.constraints', - 'default' => 'form.input.default" and "form.context..default', - ]; - $deprecations = []; - foreach ($replacements as $option => $replacement) { - if (\array_key_exists($option, $ui)) { - $deprecations[] = self::deprecation($option, $replacement); - } - } - - return $deprecations; - } - - /** - * @return list - */ - private function collectDeprecations(\Closure $callback): array - { - $deprecations = []; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { - $deprecations[] = $errstr; - - return true; - }, \E_USER_DEPRECATED); - try { - $callback(); - } finally { - restore_error_handler(); - } - - return $deprecations; - } - - private static function deprecation(string $option, string $replacement): string - { - return \sprintf('Since cleverage/ui-process-bundle 3.3: The "%s" UI option of the process "test.process" is deprecated, use "%s" instead.', $option, $replacement); - } - /** * @param array> $rawConfiguration */ diff --git a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php index a5a109c..08a6a89 100644 --- a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php +++ b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php @@ -20,8 +20,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\FileType; #[CoversClass(ProcessExtensionRuntime::class)] #[UsesClass(ProcessConfigurationsManager::class)] @@ -37,26 +35,11 @@ public function testGetUiOptions(): void $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); - $options = []; - $deprecations = []; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { - $deprecations[] = $errstr; + $options = $runtime->getUiOptions('demo.process'); - return true; - }, \E_USER_DEPRECATED); - try { - $options = $runtime->getUiOptions('demo.process'); - } finally { - restore_error_handler(); - } - - self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "demo.process" is deprecated, use "form.input.type" instead.'], $deprecations); // "default" is not compared: its nested resolution depends on the symfony/options-resolver version self::assertArrayHasKey('default', $options); unset($options['default']); - // "form" is the effective launch form, the deprecated options folded into it - self::assertArrayHasKey('form', $options); - unset($options['form']); self::assertEquals([ 'source' => 'Pim', 'target' => null, @@ -64,54 +47,8 @@ public function testGetUiOptions(): void 'ui_launch_mode' => 'modal', 'constraints' => [], 'run' => null, + 'form' => [], ], $options); self::assertSame([], $runtime->getUiOptions('demo.unknown')); } - - /** - * A process with the "form" option: the effective launch form is returned, without deprecation. - */ - public function testGetUiOptionsWithAForm(): void - { - $configuration = new ProcessConfiguration('demo.process', [], ['ui' => ['ui_launch_mode' => 'form', 'form' => [ - 'input' => ['type' => FileType::class], - 'context' => ['delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',']]]], - 'allow_extra_context' => false, - ]]]); - $registry = $this->createStub(ProcessConfigurationRegistry::class); - $registry->method('hasProcessConfiguration')->willReturn(true); - $registry->method('getProcessConfiguration')->willReturn($configuration); - $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); - - $options = []; - $deprecations = []; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { - $deprecations[] = $errstr; - - return true; - }, \E_USER_DEPRECATED); - try { - $options = $runtime->getUiOptions('demo.process'); - } finally { - restore_error_handler(); - } - - self::assertSame([], $deprecations); - if ([] === $options) { - self::fail('The UI options of a known process are expected.'); - } - self::assertSame('form', $options['ui_launch_mode']); - self::assertSame( - [ - 'input' => ['type' => FileType::class, 'options' => [], 'default' => null], - 'context' => ['delimiter' => ['type' => ChoiceType::class, 'options' => ['choices' => [',' => ',']], 'default' => ',']], - 'allow_extra_context' => false, - 'constraints' => [], - ], - $options['form'] - ); - // The deprecated options keep their default values - self::assertSame('text', $options['entrypoint_type']); - self::assertSame([], $options['constraints']); - } } From b66b4b426398ba06192eacc9e912d2817174a57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:12:02 +0200 Subject: [PATCH 10/17] #160 - Mark ContextValueNormalizer @internal Review of #168: no new public API without a caller. - ContextValueNormalizer (instantiated by LaunchAction) is @internal. - The other launch form internals are already internal since the previous commit: LaunchFormOptionsResolver, getLaunchFormOptions() and the "launch_form" option of LaunchType; getLaunchFormDefaults() and buildLaunchFormDefaults() (only used by the tests) were removed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/Form/ContextValueNormalizer.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef78df..8f72390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/src/Form/ContextValueNormalizer.php b/src/Form/ContextValueNormalizer.php index ed2bf7a..25b3a93 100644 --- a/src/Form/ContextValueNormalizer.php +++ b/src/Form/ContextValueNormalizer.php @@ -15,6 +15,8 @@ /** * Converts the typed data of the launch form (enums, dates, durations, objects...) into values that can be given to a process. + * + * @internal */ final class ContextValueNormalizer { From 69185727ca9c6a66e7aa9aeb03a9d5eee2eeea7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:14:38 +0200 Subject: [PATCH 11/17] #160 - Refuse a scalar default on an enum field, document !php/enum Review of #168: the default value of an EnumType field written as a YAML scalar cannot be transformed by the field, which expects an enum case. - LaunchFormOptionsResolver: the default value of an EnumType field must be a case of the enum of options.class (a list of cases with "multiple"), otherwise an InvalidOptionsException suggests the "!php/enum" YAML tag. - The "!php/enum" tag goes through the process bundle configuration and the container: functional test with a backed enum field (pre-selected default, value of the case given to the process). - Reference documentation (default values of the fields) and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/reference/02-process_ui_options.md | 3 ++ src/Form/LaunchFormOptionsResolver.php | 21 ++++++++ tests/App/config/packages.yaml | 14 ++++++ tests/Form/LaunchFormOptionsResolverTest.php | 51 ++++++++++++++++++++ tests/Functional/LaunchActionTest.php | 21 ++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f72390..c74b072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index f3d444f..d6a2757 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -184,6 +184,9 @@ context and of the default values): use `default` for the initial value. - for `DateType`, `DateTimeType` and `TimeType`, the YAML value is a string: set `options.input: string` (with the matching `options.input_format`), or keep a `null` default, otherwise the string cannot be transformed; - for `ChoiceType`, the default value is one of the choices values; +- for `EnumType`, the default value is a case of the enum of `options.class` (a list of cases with + `options.multiple: true`), given with the `!php/enum` YAML tag: `default: !php/enum App\Enum\Status::Active`. Any + other value is refused when the launch form is resolved; - for `DateIntervalType`, the YAML value is a string: set `options.input: string` (an ISO 8601 duration such as `P1D`), or keep a `null` default. diff --git a/src/Form/LaunchFormOptionsResolver.php b/src/Form/LaunchFormOptionsResolver.php index 91a5d56..4c214c6 100644 --- a/src/Form/LaunchFormOptionsResolver.php +++ b/src/Form/LaunchFormOptionsResolver.php @@ -16,6 +16,7 @@ use CleverAge\ProcessBundle\Validator\ConstraintLoader; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use Symfony\Component\Form\ButtonTypeInterface; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormConfigBuilder; @@ -186,10 +187,30 @@ private function resolveField(array $spec, string $path, string $processCode, bo throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" cannot upload multiple files.', $path, $processCode)); } } + if (is_a($field['type'], EnumType::class, true) && null !== $field['default']) { + $this->checkEnumDefault($field, $path, $processCode); + } return $field; } + /** + * The default value of an enum field is an enum case (a list of cases with "multiple"): a YAML scalar would not be + * transformed by the field. + * + * @param LaunchFormField $field + */ + private function checkEnumDefault(array $field, string $path, string $processCode): void + { + $class = $field['options']['class'] ?? null; + $cases = true === ($field['options']['multiple'] ?? false) && \is_array($field['default']) ? $field['default'] : [$field['default']]; + foreach ($cases as $case) { + if (!$case instanceof \UnitEnum || (\is_string($class) && !$case instanceof $class)) { + throw new InvalidOptionsException(\sprintf('The default value of the launch form field "%s" of the process "%s" must be a case of the enum "%s" (with "multiple", a list of cases), "%s" given: use the "!php/enum" YAML tag, e.g. "default: !php/enum App\\Enum\\Status::Active".', $path, $processCode, \is_string($class) ? $class : 'options.class', get_debug_type($case))); + } + } + } + /** * @return class-string> */ diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index d470647..e1f8348 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -219,6 +219,20 @@ clever_age_process: tasks: data: service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_enum_default: + description: 'Process launched with the UI form, with a backed enum field and its default value' + options: + ui: + ui_launch_mode: form + form: + context: + delimiter: + type: Symfony\Component\Form\Extension\Core\Type\EnumType + default: !php/enum CleverAge\UiProcessBundle\Tests\Form\Fixtures\StringBackedEnum::Comma + options: { class: CleverAge\UiProcessBundle\Tests\Form\Fixtures\StringBackedEnum } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' test.form_object: description: 'Process launched with the UI form, with a field returning an object that is not stringable' entry_point: data diff --git a/tests/Form/LaunchFormOptionsResolverTest.php b/tests/Form/LaunchFormOptionsResolverTest.php index 5481020..1827463 100644 --- a/tests/Form/LaunchFormOptionsResolverTest.php +++ b/tests/Form/LaunchFormOptionsResolverTest.php @@ -19,6 +19,8 @@ use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use CleverAge\UiProcessBundle\Tests\App\Validator\Even; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\StringBackedEnum; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; @@ -26,6 +28,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; @@ -313,6 +316,54 @@ public static function provideInvalidForms(): iterable ]; } + #[DataProvider('provideValidEnumDefaults')] + public function testValidEnumDefault(mixed $default, bool $multiple): void + { + [$form] = $this->resolveLaunchForm(['form' => ['context' => ['delimiter' => [ + 'type' => EnumType::class, + 'default' => $default, + 'options' => ['class' => StringBackedEnum::class, 'multiple' => $multiple], + ]]]]); + + self::assertSame($default, $form['context']['delimiter']['default']); + } + + /** + * @return iterable + */ + public static function provideValidEnumDefaults(): iterable + { + yield 'case' => [StringBackedEnum::Comma, false]; + yield 'no default' => [null, false]; + yield 'list of cases' => [[StringBackedEnum::Comma], true]; + yield 'empty list' => [[], true]; + } + + /** + * @param array $options + */ + #[DataProvider('provideInvalidEnumDefaults')] + public function testInvalidEnumDefault(mixed $default, array $options, string $enum, string $given): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The default value of the launch form field "form.context.delimiter" of the process "test.process" must be a case of the enum "%s" (with "multiple", a list of cases), "%s" given: use the "!php/enum" YAML tag, e.g. "default: !php/enum App\\Enum\\Status::Active".', $enum, $given)); + $this->resolveLaunchForm(['form' => ['context' => ['delimiter' => ['type' => EnumType::class, 'default' => $default, 'options' => $options]]]]); + } + + /** + * @return iterable, string, string}> + */ + public static function provideInvalidEnumDefaults(): iterable + { + $options = ['class' => StringBackedEnum::class]; + + yield 'scalar' => [',', $options, StringBackedEnum::class, 'string']; + yield 'case of another enum' => [PureEnum::Foo, $options, StringBackedEnum::class, PureEnum::class]; + yield 'list without multiple' => [[StringBackedEnum::Comma], $options, StringBackedEnum::class, 'array']; + yield 'list of scalars' => [[','], $options + ['multiple' => true], StringBackedEnum::class, 'string']; + yield 'without class' => [',', [], 'options.class', 'string']; + } + public function testFormConstraints(): void { [$form, $deprecations] = $this->resolveLaunchForm(['form' => ['constraints' => [['NotBlank' => null]]]]); diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 9801883..3cd67f4 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -39,6 +39,7 @@ use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; use CleverAge\UiProcessBundle\Tests\Form\Fixtures\Entity; use CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\StringBackedEnum; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; use CleverAge\UiProcessBundle\Twig\Extension\MD5Extension; use CleverAge\UiProcessBundle\Twig\Extension\ProcessExecutionExtension; @@ -490,6 +491,26 @@ public function testGlobalConstraintOnADeclaredField(): void self::assertSame(['execution_user' => 'user@example.com', 'code' => 'ABC'], $messages[0]->context); } + /** + * The default value of an enum field is an enum case, given with the "!php/enum" YAML tag. + */ + public function testEnumFieldDefault(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_enum_default'); + self::assertResponseIsSuccessful(); + self::assertSame(',', $crawler->filter('select[name="launch[context][delimiter]"] option[selected]')->attr('value')); + + $this->client->submit($crawler->selectButton('Launch')->form()); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + // The value of the case of the backed enum + self::assertSame(['execution_user' => 'user@example.com', 'delimiter' => StringBackedEnum::Comma->value], $messages[0]->context); + } + public function testPureEnumField(): void { $this->login(); From 505f7e5d762320b8f3b24968d092d71edfd33b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:22:21 +0200 Subject: [PATCH 12/17] #160 - Check the form UI option at compile time Review of #168: an invalid launch form configuration was only reported when the launch page was opened (500). - New internal CheckLaunchFormsPass, registered by the bundle: for each process declaring the "form" UI option, the launch form is resolved (the deprecated options folded into it) when the container is compiled; an invalid launch form fails the compilation with a message naming the process (type, forbidden options, file fields, keys, enum default, constraints, conflicts with the deprecated options). - Only the UI options are checked: the tasks and the other options of the process are not concerned. A process without "form" is not checked (no BC break), a launch form using an environment variable is left to the launch page, and the parameters are resolved. The deprecations stay triggered on the launch page only. - The options of the form types themselves are still resolved by the form factory when the form is built, and the submitted values are converted after the submission. - Tests: the pass and its registration; the tests booting the test kernel declare the classes used at compile time (strict coverage metadata). - Reference documentation and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/reference/02-process_ui_options.md | 7 +- src/CleverAgeUiProcessBundle.php | 2 + .../Compiler/CheckLaunchFormsPass.php | 91 +++++++ tests/CleverAgeUiProcessBundleTest.php | 14 ++ .../Compiler/CheckLaunchFormsPassTest.php | 231 ++++++++++++++++++ tests/Functional/HttpProcessExecuteTest.php | 4 + tests/Functional/LaunchActionTest.php | 2 + tests/Functional/LogRecordCrudTest.php | 4 + tests/Functional/ProcessExecutionCrudTest.php | 4 + .../ProcessExecutionNotificationTest.php | 4 + tests/Functional/ProcessListTest.php | 4 + tests/Functional/ProcessScheduleCrudTest.php | 4 + tests/Functional/PurgeLogsCommandTest.php | 4 + tests/Functional/SecurityTest.php | 4 + tests/Functional/UserCrudTest.php | 4 + ...ocessExecutionManagerEntityManagerTest.php | 6 + ...octrineProcessHandlerEntityManagerTest.php | 6 + .../ProcessExecutionRepositoryTest.php | 6 + .../ProcessScheduleRepositoryTest.php | 6 + .../ProcessExecutionStopCheckerTest.php | 6 + 21 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 src/DependencyInjection/Compiler/CheckLaunchFormsPass.php create mode 100644 tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c74b072..e9a911a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index d6a2757..b1b8a56 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -42,8 +42,9 @@ clever_age_process: Every key is optional: a process without `options.ui` is displayed and launched with a confirmation modal. Unknown keys, or invalid values, raise an options resolver error when the process list is displayed. The `form` option (and -its interaction with the deprecated options) is only resolved when the launch form of the process is displayed: an -invalid `form` option only breaks the launch page of its process. +its interaction with the deprecated options) is not resolved by the process list: it is checked when the container +is compiled (an invalid launch form fails the compilation, e.g. `cache:clear`, with a message naming the process), +and resolved again when the launch form of the process is displayed. The `notification` key, next to `ui`, configures the [notification](09-notifications.md) of the end of the process executions. The `logs` key, next to `ui`, configures the [log retention](04-process_executions_and_logs.md#log-retention) @@ -158,7 +159,7 @@ or `_`). A purely numeric key (e.g. `123`, even quoted in YAML) is not supported `type` is the FQCN of a form type: a Symfony core type (`Symfony\Component\Form\Extension\Core\Type\*`), a type of a bridge (e.g. `Symfony\Bridge\Doctrine\Form\Type\EntityType`, if installed) or a type of your project. It must implement `Symfony\Component\Form\FormTypeInterface` and must not be a button type; the short names (`text`, `choice`...) are -not supported. Only the existence of the class is checked when the options are resolved: a form type with +not supported. Only the existence of the class is checked when the container is compiled: a form type with dependencies must be a service (tag `form.type`, autoconfigured by default), and an option that the type does not define (e.g. `choices` on a `TextType` field) fails when the form is built (500 on the launch page). diff --git a/src/CleverAgeUiProcessBundle.php b/src/CleverAgeUiProcessBundle.php index ebe3bd1..7851daf 100644 --- a/src/CleverAgeUiProcessBundle.php +++ b/src/CleverAgeUiProcessBundle.php @@ -14,6 +14,7 @@ namespace CleverAge\UiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -31,5 +32,6 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new CheckDoctrineLazyGhostObjectsPass()); + $container->addCompilerPass(new CheckLaunchFormsPass()); } } diff --git a/src/DependencyInjection/Compiler/CheckLaunchFormsPass.php b/src/DependencyInjection/Compiler/CheckLaunchFormsPass.php new file mode 100644 index 0000000..4d75874 --- /dev/null +++ b/src/DependencyInjection/Compiler/CheckLaunchFormsPass.php @@ -0,0 +1,91 @@ +hasDefinition(self::REGISTRY_ID)) { + return; + } + $configurations = $container->getDefinition(self::REGISTRY_ID)->getArgument(0); + if (!\is_array($configurations)) { + return; + } + + $parameterBag = $container->getParameterBag(); + foreach ($configurations as $processCode => $configuration) { + $ui = \is_array($configuration) && \is_array($configuration['options'] ?? null) ? $configuration['options']['ui'] ?? null : null; + if (!\is_array($ui) || !\array_key_exists('form', $ui)) { + continue; + } + $usedEnvs = []; + $container->resolveEnvPlaceholders($ui, null, $usedEnvs); + if ([] !== $usedEnvs) { + continue; + } + /** @var array $ui */ + $ui = $parameterBag->unescapeValue($parameterBag->resolveValue($ui)); + + $this->checkLaunchForm((string) $processCode, $ui); + } + } + + /** + * @param array $ui + */ + private function checkLaunchForm(string $processCode, array $ui): void + { + // Only the UI options of the process: its tasks and its other options are not concerned + $registry = new ProcessConfigurationRegistry([$processCode => [ + 'options' => ['ui' => $ui], + 'entry_point' => null, + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => true, + 'tasks' => [], + ]], 'stop'); + + // The deprecations of the launch form are triggered when it is displayed + set_error_handler(static fn (): bool => true, \E_USER_DEPRECATED); + try { + (new ProcessConfigurationsManager($registry))->getLaunchFormOptions($processCode); + } catch (\Throwable $e) { + throw new InvalidArgumentException(\sprintf('The launch form ("form" UI option) of the process "%s" is invalid: %s', $processCode, $e->getMessage()), 0, $e); + } finally { + restore_error_handler(); + } + } +} diff --git a/tests/CleverAgeUiProcessBundleTest.php b/tests/CleverAgeUiProcessBundleTest.php index b900fa2..dd58f3a 100644 --- a/tests/CleverAgeUiProcessBundleTest.php +++ b/tests/CleverAgeUiProcessBundleTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; @@ -26,6 +27,7 @@ #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(CheckLaunchFormsPass::class)] class CleverAgeUiProcessBundleTest extends TestCase { public function testPathIsTheBundleRootDirectory(): void @@ -56,4 +58,16 @@ public function testDoctrineLazyGhostObjectsCheckIsRegistered(): void ); self::assertCount(1, $passes); } + + public function testLaunchFormsCheckIsRegistered(): void + { + $container = new ContainerBuilder(); + (new CleverAgeUiProcessBundle())->build($container); + + $passes = array_filter( + $container->getCompilerPassConfig()->getPasses(), + static fn (object $pass): bool => $pass instanceof CheckLaunchFormsPass, + ); + self::assertCount(1, $passes); + } } diff --git a/tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php b/tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php new file mode 100644 index 0000000..192f270 --- /dev/null +++ b/tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php @@ -0,0 +1,231 @@ + $ui + */ + #[DataProvider('provideValidUiOptions')] + public function testValidLaunchForms(array $ui): void + { + $container = $this->createContainer(['test.process' => $this->rawProcess($ui)]); + + $deprecations = $this->collectDeprecations(static fn () => (new CheckLaunchFormsPass())->process($container)); + + // The deprecations are triggered when the launch form is displayed + self::assertSame([], $deprecations); + } + + /** + * @return iterable}> + */ + public static function provideValidUiOptions(): iterable + { + yield 'no UI options' => [[]]; + yield 'form' => [[ + 'ui_launch_mode' => 'form', + 'form' => [ + 'input' => ['type' => FileType::class], + 'context' => [ + 'delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',', ';' => ';']]], + 'enum' => ['type' => EnumType::class, 'default' => StringBackedEnum::Comma, 'options' => ['class' => StringBackedEnum::class]], + ], + 'allow_extra_context' => false, + 'constraints' => [['NotNull' => null]], + ], + ]]; + yield 'form with deprecated options' => [['form' => ['context' => ['delimiter' => null]], 'default' => ['context' => [['key' => 'foo', 'value' => 'bar']]]]]; + // Not checked: the process has no "form" option (the deprecated options are checked as before 3.3) + yield 'invalid deprecated option without form' => [['entrypoint_type' => 'csv']]; + } + + /** + * @param array $ui + */ + #[DataProvider('provideInvalidUiOptions')] + public function testInvalidLaunchForms(array $ui, string $message): void + { + $container = $this->createContainer(['valid.process' => $this->rawProcess(), 'test.process' => $this->rawProcess($ui)]); + + try { + (new CheckLaunchFormsPass())->process($container); + self::fail('An invalid launch form is expected to fail the compilation.'); + } catch (InvalidArgumentException $e) { + self::assertSame(\sprintf('The launch form ("form" UI option) of the process "test.process" is invalid: %s', $message), $e->getMessage()); + self::assertInstanceOf(InvalidOptionsException::class, $e->getPrevious()); + } + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidUiOptions(): iterable + { + yield 'unknown type' => [ + ['form' => ['input' => ['type' => 'file']]], + 'The type "file" of the launch form field "form.input" of the process "test.process" must be the FQCN of a form type, the class does not exist.', + ]; + yield 'file in the context' => [ + ['form' => ['context' => ['data' => ['type' => FileType::class]]]], + 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', + ]; + yield 'scalar enum default' => [ + ['form' => ['context' => ['enum' => ['type' => EnumType::class, 'default' => ',', 'options' => ['class' => StringBackedEnum::class]]]]], + \sprintf('The default value of the launch form field "form.context.enum" of the process "test.process" must be a case of the enum "%s" (with "multiple", a list of cases), "string" given: use the "!php/enum" YAML tag, e.g. "default: !php/enum App\Enum\Status::Active".', StringBackedEnum::class), + ]; + yield 'conflict with a deprecated option' => [ + ['entrypoint_type' => 'file', 'form' => ['input' => ['type' => FileType::class]]], + 'The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.', + ]; + yield 'not an array' => [['form' => 'file'], 'The "form" UI option of the process "test.process" must be an array, "string" given.']; + } + + /** + * Only the UI options are checked: the tasks and the other options of the process are not concerned. + */ + #[DoesNotPerformAssertions] + public function testOtherOptionsAreNotChecked(): void + { + /** @var array $process */ + $process = array_replace_recursive($this->rawProcess(['form' => ['context' => ['delimiter' => null]]]), [ + 'options' => ['notification' => ['enabled' => 'yes']], + 'tasks' => ['data' => ['outputs' => ['missing']]], + ]); + $container = $this->createContainer(['test.process' => $process]); + + (new CheckLaunchFormsPass())->process($container); + } + + public function testParametersAreResolved(): void + { + $container = $this->createContainer(['test.process' => $this->rawProcess(['form' => ['input' => ['type' => '%input_type%']]])]); + $container->setParameter('input_type', 'file'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The type "file" of the launch form field "form.input"'); + + (new CheckLaunchFormsPass())->process($container); + } + + /** + * A launch form using an environment variable is resolved when it is displayed. + */ + public function testEnvironmentVariablesAreNotResolved(): void + { + $container = new ContainerBuilder(); + // The placeholder of an environment variable, as resolved by the configuration of the process bundle + $type = $container->getParameterBag()->get('env(INPUT_TYPE)'); + self::assertIsString($type); + $container->setDefinition(CheckLaunchFormsPass::REGISTRY_ID, new Definition(\stdClass::class, [ + ['test.process' => $this->rawProcess(['form' => ['input' => ['type' => $type]]])], + 'stop', + [], + ])); + + (new CheckLaunchFormsPass())->process($container); + } + + #[DoesNotPerformAssertions] + public function testWithoutTheProcessBundle(): void + { + (new CheckLaunchFormsPass())->process(new ContainerBuilder()); + } + + /** + * @param array> $configurations + */ + private function createContainer(array $configurations): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setDefinition(CheckLaunchFormsPass::REGISTRY_ID, new Definition(\stdClass::class, [$configurations, 'stop', []])); + + return $container; + } + + /** + * Raw configuration of a process with a single task, as the process bundle configuration would give it. + * + * @param array $ui + * + * @return array + */ + private function rawProcess(array $ui = []): array + { + return [ + 'options' => [] === $ui ? [] : ['ui' => $ui], + 'entry_point' => null, + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => true, + 'tasks' => [ + 'data' => [ + 'service' => '@CleverAge\ProcessBundle\Task\DummyTask', + 'options' => [], + 'description' => '', + 'help' => '', + 'outputs' => [], + 'errors' => [], + 'error_outputs' => [], + 'error_strategy' => null, + 'log_level' => null, + ], + ], + ]; + } + + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } +} diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index a291bfd..98a3c87 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -22,11 +22,13 @@ use CleverAge\UiProcessBundle\Controller\ProcessExecuteController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Http\Model\HttpProcessExecution; use CleverAge\UiProcessBundle\Http\ValueResolver\HttpProcessExecuteValueResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; @@ -57,6 +59,8 @@ #[CoversClass(HttpProcessExecutionAuthenticator::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessDashboardController::class)] #[UsesClass(ProcessExecutionCrudController::class)] diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 3cd67f4..f3796fd 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -23,6 +23,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; @@ -71,6 +72,7 @@ #[UsesClass(ProcessExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Functional/LogRecordCrudTest.php b/tests/Functional/LogRecordCrudTest.php index 3bd0b29..86afc3d 100644 --- a/tests/Functional/LogRecordCrudTest.php +++ b/tests/Functional/LogRecordCrudTest.php @@ -24,11 +24,13 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -61,6 +63,8 @@ #[UsesClass(LogLevelExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/ProcessExecutionCrudTest.php b/tests/Functional/ProcessExecutionCrudTest.php index 8b89705..678a9ec 100644 --- a/tests/Functional/ProcessExecutionCrudTest.php +++ b/tests/Functional/ProcessExecutionCrudTest.php @@ -24,12 +24,14 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -67,6 +69,8 @@ #[UsesClass(ProcessExecutionExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/ProcessExecutionNotificationTest.php b/tests/Functional/ProcessExecutionNotificationTest.php index a1fc04c..a02812b 100644 --- a/tests/Functional/ProcessExecutionNotificationTest.php +++ b/tests/Functional/ProcessExecutionNotificationTest.php @@ -17,10 +17,12 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -41,6 +43,8 @@ #[CoversClass(ProcessExecutionEndedEvent::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/ProcessListTest.php b/tests/Functional/ProcessListTest.php index 2cda30d..0e7e966 100644 --- a/tests/Functional/ProcessListTest.php +++ b/tests/Functional/ProcessListTest.php @@ -22,9 +22,11 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -56,6 +58,8 @@ #[UsesClass(ProcessExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index f723f44..d2f95dd 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -22,11 +22,13 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; use CleverAge\UiProcessBundle\Entity\ProcessSchedule; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -64,6 +66,8 @@ #[UsesClass(IsValidProcessCodeValidator::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/PurgeLogsCommandTest.php b/tests/Functional/PurgeLogsCommandTest.php index ee526b9..b838b0f 100644 --- a/tests/Functional/PurgeLogsCommandTest.php +++ b/tests/Functional/PurgeLogsCommandTest.php @@ -18,10 +18,12 @@ use CleverAge\UiProcessBundle\Command\PurgeLogsCommand; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; @@ -35,6 +37,8 @@ #[CoversClass(PurgeLogsCommand::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/SecurityTest.php b/tests/Functional/SecurityTest.php index 402c5fd..54525eb 100644 --- a/tests/Functional/SecurityTest.php +++ b/tests/Functional/SecurityTest.php @@ -29,9 +29,11 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -72,6 +74,8 @@ #[UsesClass(ProcessExtension::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/UserCrudTest.php b/tests/Functional/UserCrudTest.php index d0738e2..72b9ab4 100644 --- a/tests/Functional/UserCrudTest.php +++ b/tests/Functional/UserCrudTest.php @@ -21,9 +21,11 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -48,6 +50,8 @@ #[UsesClass(ProcessExtension::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php index 8c4bbb2..1f2ee46 100644 --- a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php +++ b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php @@ -16,11 +16,14 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; @@ -44,6 +47,9 @@ #[CoversClass(DoctrineProcessHandler::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php index e7b8b55..c00bc6b 100644 --- a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php +++ b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php @@ -16,11 +16,14 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; @@ -39,6 +42,9 @@ #[CoversClass(DoctrineProcessHandler::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Repository/ProcessExecutionRepositoryTest.php b/tests/Repository/ProcessExecutionRepositoryTest.php index 76152fb..ff976aa 100644 --- a/tests/Repository/ProcessExecutionRepositoryTest.php +++ b/tests/Repository/ProcessExecutionRepositoryTest.php @@ -16,10 +16,13 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use CleverAge\UiProcessBundle\Tests\App\TestKernel; @@ -34,6 +37,9 @@ #[CoversClass(ProcessExecutionRepository::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Repository/ProcessScheduleRepositoryTest.php b/tests/Repository/ProcessScheduleRepositoryTest.php index 0aa1c40..7739a9d 100644 --- a/tests/Repository/ProcessScheduleRepositoryTest.php +++ b/tests/Repository/ProcessScheduleRepositoryTest.php @@ -16,10 +16,13 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; use CleverAge\UiProcessBundle\Entity\ProcessSchedule; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Repository\ProcessScheduleRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use CleverAge\UiProcessBundle\Tests\App\TestKernel; @@ -32,6 +35,9 @@ #[CoversClass(ProcessScheduleRepository::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/StopChecker/ProcessExecutionStopCheckerTest.php b/tests/StopChecker/ProcessExecutionStopCheckerTest.php index 5ef6448..a656a30 100644 --- a/tests/StopChecker/ProcessExecutionStopCheckerTest.php +++ b/tests/StopChecker/ProcessExecutionStopCheckerTest.php @@ -19,9 +19,12 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; +use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; @@ -37,6 +40,9 @@ #[CoversClass(ProcessExecutionStopChecker::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] From e63af6ae5da2963a0f443e9746f657d30b3815a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:27:07 +0200 Subject: [PATCH 13/17] #160 - Report a non-convertible launch form value as a form error instead of a 500 Review of #168: a value that cannot be converted into a process value was raised as a LogicException (500) after the user filled and submitted the form. - LaunchAction converts the input and the context before saving the uploaded file and dispatching; a value that cannot be converted is reported as an error of its field (the input, a declared context field, or the free context pairs), the form is displayed again and nothing is dispatched. - The message of ContextValueNormalizer names a ChoiceType instead of the removed "choice" alias. - Functional tests for the input and a context field; reference documentation and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/reference/02-process_ui_options.md | 12 +++-- src/Controller/Admin/Process/LaunchAction.php | 53 ++++++++++++++----- src/Form/ContextValueNormalizer.php | 2 +- tests/App/config/packages.yaml | 3 ++ tests/Form/ContextValueNormalizerTest.php | 2 +- tests/Functional/LaunchActionTest.php | 39 ++++++++------ 7 files changed, 78 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a911a..3d28f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`; a value that cannot be converted is reported as an error of its field) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index b1b8a56..2ac4a7d 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -226,16 +226,18 @@ constraints apply to the typed values): | pure enum | its `name` | | `DateTimeInterface` | formatted as `DATE_ATOM` (`2026-01-02T03:04:05+01:00`) | | `DateInterval` | an ISO 8601 duration with every part (`P0Y0M1DT0H0M0S`), `-` prefixed when inverted | -| file | refused (`LogicException`): file fields are only supported for the input | +| file | refused: file fields are only supported for the input | | `Stringable` object | its string | -| other object | refused (`LogicException`, 500) | +| other object | refused | -A declared field left empty gives the `null` value (`false` for a `CheckboxType`). +A declared field left empty gives the `null` value (`false` for a `CheckboxType`). A value that cannot be converted +is reported as an error under its field (under the free context pairs for a free pair), and the process is not +launched. > **Object data.** The data of some form types is an object (e.g. the entity of an `EntityType`): the `choice_value` > option only changes the HTML value of the options, not the data given to the process. Such an object must -> implement `\Stringable`: its string becomes the context value. Otherwise the launch fails with a `LogicException` -> ("cannot be used as a process context value"). Prefer a `ChoiceType` field with scalar choices (e.g. the +> implement `\Stringable`: its string becomes the context value. Otherwise an error is displayed under the field +> ("cannot be used as a process context value") and the process is not launched. Prefer a `ChoiceType` field with scalar choices (e.g. the > identifiers of the entities). Default values diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index 3da69df..f83b6ea 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -24,6 +24,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -88,25 +90,16 @@ public function __invoke( $form->setData($launchForm['data']); } $form->handleRequest($this->requestStack->getMainRequest()); - if ($form->isSubmitted() && $form->isValid()) { - // Normalized after the validation: the constraints apply to the typed values - $normalizer = new ContextValueNormalizer(); - $input = $form->get('input')->getData(); + // Normalized after the validation: the constraints apply to the typed values + if ($form->isSubmitted() && $form->isValid() && null !== $values = $this->normalizeValues($form)) { + [$input, $context] = $values; if ($input instanceof UploadedFile) { $filename = \sprintf('%s/%s.%s', $uploadDirectory, Uuid::v4(), $input->getClientOriginalExtension()); (new Filesystem())->dumpFile($filename, $input->getContent()); $input = $filename; - } else { - $input = $normalizer->normalize($input, 'input'); } /** @var string $processCode */ $processCode = $form->getConfig()->getOption('process_code'); - /** @var array $data */ - $data = $form->get('context')->getData() ?? []; - $context = []; - foreach ($data as $key => $value) { - $context[(string) $key] = $normalizer->normalize($value, (string) $key); - } $this->dispatch($processCode, $input, $context); $this->addFlash( 'success', @@ -125,6 +118,42 @@ public function __invoke( ); } + /** + * The input (an uploaded file is kept as is) and the context of the submitted form, converted into process values; + * null when a value cannot be converted, the error being reported on its field. + * + * @param FormInterface|null> $form + * + * @return array{mixed, array}|null + */ + private function normalizeValues(FormInterface $form): ?array + { + $normalizer = new ContextValueNormalizer(); + $field = $form->get('input'); + try { + $input = $field->getData(); + if (!$input instanceof UploadedFile) { + $input = $normalizer->normalize($input, 'input'); + } + $contextForm = $form->get('context'); + /** @var array $data */ + $data = $contextForm->getData() ?? []; + $context = []; + foreach ($data as $key => $value) { + $key = (string) $key; + // A declared field, or the free context pairs + $field = $contextForm->has($key) ? $contextForm->get($key) : $contextForm; + $context[$key] = $normalizer->normalize($value, $key); + } + } catch (\LogicException $e) { + $field->addError(new FormError($e->getMessage())); + + return null; + } + + return [$input, $context]; + } + /** * @param array $context */ diff --git a/src/Form/ContextValueNormalizer.php b/src/Form/ContextValueNormalizer.php index 25b3a93..532cb93 100644 --- a/src/Form/ContextValueNormalizer.php +++ b/src/Form/ContextValueNormalizer.php @@ -62,6 +62,6 @@ public function normalize(mixed $value, string $name): mixed return (string) $value; } - throw new \LogicException(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', $name, get_debug_type($value))); + throw new \LogicException(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. a ChoiceType with scalar choices), or make the class implement \Stringable.', $name, get_debug_type($value))); } } diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index e1f8348..c468e1a 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -240,6 +240,9 @@ clever_age_process: ui: ui_launch_mode: form form: + input: + type: CleverAge\UiProcessBundle\Tests\App\Form\EntityIdType + options: { required: false } context: entity: type: CleverAge\UiProcessBundle\Tests\App\Form\EntityIdType diff --git a/tests/Form/ContextValueNormalizerTest.php b/tests/Form/ContextValueNormalizerTest.php index 43517f4..2dba254 100644 --- a/tests/Form/ContextValueNormalizerTest.php +++ b/tests/Form/ContextValueNormalizerTest.php @@ -87,7 +87,7 @@ public function testFile(): void public function testInvalidValue(mixed $value, string $name, string $debugType): void { $this->expectException(\LogicException::class); - $this->expectExceptionMessage(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', $name, $debugType)); + $this->expectExceptionMessage(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. a ChoiceType with scalar choices), or make the class implement \Stringable.', $name, $debugType)); (new ContextValueNormalizer())->normalize($value, 'k'); } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index f3796fd..6f3ccde 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -534,7 +534,7 @@ public function testPureEnumField(): void /** * A field whose data is an object that cannot be converted (as an EntityType on an entity that is not - * \Stringable): the launch fails with a LogicException (a 500), nothing is dispatched. + * \Stringable): the error is displayed under the field, nothing is dispatched. */ public function testContextValueThatCannotBeConverted(): void { @@ -546,23 +546,32 @@ public function testContextValueThatCannotBeConverted(): void /** @var array> $values */ $values = $form->getPhpValues(); $values['launch']['context'] = ['entity' => '42']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); - $this->client->catchExceptions(false); - try { - $this->client->request($form->getMethod(), $form->getUri(), $values); - self::fail('A LogicException is expected'); - } catch (\LogicException $exception) { - self::assertSame( - \sprintf('The value of "entity" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', Entity::class), - $exception->getMessage() - ); - } + self::assertResponseIsSuccessful(); + self::assertSame( + \sprintf('The value of "entity" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. a ChoiceType with scalar choices), or make the class implement \Stringable.', Entity::class), + $this->getFieldError($crawler, 'launch_context_entity') + ); self::assertSame([], $this->getDispatchedMessages()); + } - // Caught by the kernel: a 500 - $this->client->catchExceptions(true); - $this->client->request($form->getMethod(), $form->getUri(), $values); - self::assertResponseStatusCodeSame(500); + public function testInputThatCannotBeConverted(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=cleverage_ui_process_launch&process=test.form_object'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['input'] = '7'; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame( + \sprintf('The value of "input" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. a ChoiceType with scalar choices), or make the class implement \Stringable.', Entity::class), + $this->getFieldError($crawler, 'launch_input') + ); self::assertSame([], $this->getDispatchedMessages()); } From eef9ada215d7c242e271aa2e67ff0fe9fe52d807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:36:51 +0200 Subject: [PATCH 14/17] #160 - Check the launch forms with the form factory each time the container is built The compiler pass only checks the structure of the launch forms: the services (form types, type extensions, constraint validators) do not exist yet when the container is compiled. The options of the form types, the default values and the constraint validators were still only checked on the launch page (500). - New internal LaunchFormsCacheWarmer, not optional: Symfony runs it after each build of the container (cache:clear, cache:warmup, and the automatic rebuild in debug mode). For each process declaring the "form" UI option, it builds the launch form with the form factory of the application (form type options and type extensions, "constraints" option), sets its default values (transformed by the fields), and resolves the validator of each constraint, nested constraints included (Collection, All...). An invalid launch form fails with a message naming the process. - Registered without explicit tag nor arguments: config/services/ cache_warmer.yaml loads src/CacheWarmer/ with autowire and autoconfigure (kernel.cache_warmer tag from FrameworkBundle), #[Autowire] for the process configurations manager and the (optional) validator factory. - Tests: the warmer (valid forms, unknown or invalid form type options, default value that cannot be transformed, missing validators, without validator), its run with the container of the test application; the tests booting the test kernel declare the classes used by the warmer. - Reference documentation and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- config/services/cache_warmer.yaml | 5 + docs/reference/02-process_ui_options.md | 19 +- src/CacheWarmer/LaunchFormsCacheWarmer.php | 123 ++++++++++ tests/App/Validator/WithoutValidator.php | 27 +++ .../LaunchFormsCacheWarmerTest.php | 220 ++++++++++++++++++ tests/Functional/HttpProcessExecuteTest.php | 6 + tests/Functional/LaunchActionTest.php | 2 + tests/Functional/LogRecordCrudTest.php | 6 + tests/Functional/ProcessExecutionCrudTest.php | 6 + .../ProcessExecutionNotificationTest.php | 6 + tests/Functional/ProcessListTest.php | 19 ++ tests/Functional/ProcessScheduleCrudTest.php | 4 + tests/Functional/PurgeLogsCommandTest.php | 6 + tests/Functional/SecurityTest.php | 6 + tests/Functional/UserCrudTest.php | 6 + ...ocessExecutionManagerEntityManagerTest.php | 6 + ...octrineProcessHandlerEntityManagerTest.php | 6 + .../ProcessExecutionRepositoryTest.php | 6 + .../ProcessScheduleRepositoryTest.php | 6 + .../ProcessExecutionStopCheckerTest.php | 6 + 21 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 config/services/cache_warmer.yaml create mode 100644 src/CacheWarmer/LaunchFormsCacheWarmer.php create mode 100644 tests/App/Validator/WithoutValidator.php create mode 100644 tests/CacheWarmer/LaunchFormsCacheWarmerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d28f45..f65a7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`; a value that cannot be converted is reported as an error of its field) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`; a value that cannot be converted is reported as an error of its field) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation; then the launch forms are built with the form factory of the application each time the container is built (internal `LaunchFormsCacheWarmer`, not optional): invalid form type options, default values or constraint validators fail there. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/config/services/cache_warmer.yaml b/config/services/cache_warmer.yaml new file mode 100644 index 0000000..7aa8b71 --- /dev/null +++ b/config/services/cache_warmer.yaml @@ -0,0 +1,5 @@ +services: + CleverAge\UiProcessBundle\CacheWarmer\: + resource: '../../src/CacheWarmer/' + autowire: true + autoconfigure: true diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 2ac4a7d..61e9c3e 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -42,9 +42,16 @@ clever_age_process: Every key is optional: a process without `options.ui` is displayed and launched with a confirmation modal. Unknown keys, or invalid values, raise an options resolver error when the process list is displayed. The `form` option (and -its interaction with the deprecated options) is not resolved by the process list: it is checked when the container -is compiled (an invalid launch form fails the compilation, e.g. `cache:clear`, with a message naming the process), -and resolved again when the launch form of the process is displayed. +its interaction with the deprecated options) is not resolved by the process list. It is checked each time the +container is built (`cache:clear`, `cache:warmup`, or the automatic rebuild in debug mode), with a message naming the +process: +- its structure when the container is compiled (types, keys, file fields, default values of the enum fields, + constraints, conflicts with the deprecated options); +- then the launch form is built with the form factory of the application: the options of the form types (with the + type extensions of the application), the default values of the fields, and the validators of the constraints + (nested constraints included). + +It is resolved again when the launch form of the process is displayed. The `notification` key, next to `ui`, configures the [notification](09-notifications.md) of the end of the process executions. The `logs` key, next to `ui`, configures the [log retention](04-process_executions_and_logs.md#log-retention) @@ -159,9 +166,9 @@ or `_`). A purely numeric key (e.g. `123`, even quoted in YAML) is not supported `type` is the FQCN of a form type: a Symfony core type (`Symfony\Component\Form\Extension\Core\Type\*`), a type of a bridge (e.g. `Symfony\Bridge\Doctrine\Form\Type\EntityType`, if installed) or a type of your project. It must implement `Symfony\Component\Form\FormTypeInterface` and must not be a button type; the short names (`text`, `choice`...) are -not supported. Only the existence of the class is checked when the container is compiled: a form type with -dependencies must be a service (tag `form.type`, autoconfigured by default), and an option that the type does not -define (e.g. `choices` on a `TextType` field) fails when the form is built (500 on the launch page). +not supported. A form type with dependencies must be a service (tag `form.type`, autoconfigured by default). An +option that the type does not define (e.g. `choices` on a `TextType` field), or an invalid option value, fails when +the container is built (see [YAML configuration](#yaml-configuration)). ### Constraints of a field diff --git a/src/CacheWarmer/LaunchFormsCacheWarmer.php b/src/CacheWarmer/LaunchFormsCacheWarmer.php new file mode 100644 index 0000000..50f405b --- /dev/null +++ b/src/CacheWarmer/LaunchFormsCacheWarmer.php @@ -0,0 +1,123 @@ +processConfigurationsManager->getPublicProcesses(), ...$this->processConfigurationsManager->getPrivateProcesses()]; + foreach ($configurations as $configuration) { + $ui = $configuration->getOptions()['ui'] ?? null; + if (\is_array($ui) && \array_key_exists('form', $ui)) { + $this->checkLaunchForm($configuration); + } + } + + return []; + } + + private function checkLaunchForm(ProcessConfiguration $configuration): void + { + $processCode = $configuration->getCode(); + // The deprecations of the launch form are triggered when it is displayed + set_error_handler(static fn (): bool => true, \E_USER_DEPRECATED); + try { + $launchForm = $this->processConfigurationsManager->getLaunchFormOptions($processCode); + if (null === $launchForm) { + return; + } + $form = $this->formFactory->create(LaunchType::class, null, [ + 'constraints' => $launchForm['constraints'], + 'process_code' => $processCode, + 'launch_form' => $launchForm, + ]); + $form->setData($launchForm['data']); + + $constraints = $launchForm['constraints']; + foreach ([$launchForm['input'], ...array_values($launchForm['context'])] as $field) { + if (\is_array($field['options']['constraints'] ?? null)) { + $constraints = [...$constraints, ...$field['options']['constraints']]; + } + } + $this->checkConstraintValidators($constraints); + } catch (\Throwable $e) { + throw new \LogicException(\sprintf('The launch form ("form" UI option) of the process "%s" is invalid: %s', $processCode, $e->getMessage()), 0, $e); + } finally { + restore_error_handler(); + } + } + + /** + * @param array $constraints + */ + private function checkConstraintValidators(array $constraints): void + { + if (!$this->constraintValidatorFactory instanceof ConstraintValidatorFactoryInterface) { + return; + } + foreach ($constraints as $constraint) { + if (!$constraint instanceof Constraint) { + continue; + } + // Required and Optional only hold the constraints of a field of a Collection + if (!$constraint instanceof Existence) { + $this->constraintValidatorFactory->getInstance($constraint); + } + // The constraints of Collection, All, AtLeastOneOf, Sequentially... + if ($constraint instanceof Composite) { + $this->checkConstraintValidators($constraint->getNestedConstraints()); + } + } + } +} diff --git a/tests/App/Validator/WithoutValidator.php b/tests/App/Validator/WithoutValidator.php new file mode 100644 index 0000000..7a89d76 --- /dev/null +++ b/tests/App/Validator/WithoutValidator.php @@ -0,0 +1,27 @@ +createWarmer([])->isOptional()); + } + + /** + * @param array $ui + */ + #[DataProvider('provideValidUiOptions')] + public function testValidLaunchForms(array $ui): void + { + $warmer = $this->createWarmer(['test.process' => $this->rawProcess($ui), 'private.process' => $this->rawProcess($ui, false)]); + + $deprecations = $this->collectDeprecations(static fn () => self::assertSame([], $warmer->warmUp(sys_get_temp_dir()))); + + // The deprecations are triggered when the launch form is displayed + self::assertSame([], $deprecations); + } + + /** + * @return iterable}> + */ + public static function provideValidUiOptions(): iterable + { + yield 'no UI options' => [[]]; + yield 'form' => [[ + 'ui_launch_mode' => 'form', + 'form' => [ + 'input' => ['type' => FileType::class, 'options' => ['label' => 'CSV file', 'constraints' => [['NotNull' => null]]]], + 'context' => [ + 'delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',', ';' => ';']]], + 'count' => ['type' => IntegerType::class, 'default' => 2, 'options' => ['constraints' => [['Positive' => null], [Even::class => null]]]], + 'from' => ['type' => DateType::class, 'default' => '2026-01-02', 'options' => ['widget' => 'single_text', 'input' => 'string']], + ], + 'constraints' => [['Collection' => ['fields' => ['context' => [['Collection' => ['fields' => ['delimiter' => [['NotBlank' => null]]], 'allowExtraFields' => true]]]], 'allowExtraFields' => true]]], + ], + ]]; + yield 'form with deprecated options' => [['form' => ['context' => ['delimiter' => null]], 'default' => ['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar']]]]]; + // Not checked: the process has no "form" option + yield 'invalid deprecated option without form' => [['entrypoint_type' => 'text', 'default' => ['input' => ['not a string']]]]; + } + + /** + * @param array $form + * @param class-string<\Throwable> $previous + */ + #[DataProvider('provideInvalidLaunchForms')] + public function testInvalidLaunchForms(array $form, string $previous, string $message): void + { + $warmer = $this->createWarmer(['valid.process' => $this->rawProcess(), 'test.process' => $this->rawProcess(['form' => $form])]); + + try { + $warmer->warmUp(sys_get_temp_dir()); + self::fail('An invalid launch form is expected to fail the warmup.'); + } catch (\LogicException $e) { + self::assertStringStartsWith('The launch form ("form" UI option) of the process "test.process" is invalid: ', $e->getMessage()); + self::assertStringContainsString($message, $e->getMessage()); + self::assertInstanceOf($previous, $e->getPrevious()); + } + } + + /** + * @return iterable, class-string<\Throwable>, string}> + */ + public static function provideInvalidLaunchForms(): iterable + { + yield 'unknown option of the type' => [ + ['context' => ['comment' => ['type' => TextType::class, 'options' => ['choices' => ['a' => 'a']]]]], + UndefinedOptionsException::class, + 'The option "choices" does not exist.', + ]; + yield 'invalid option value' => [ + ['context' => ['delimiter' => ['type' => ChoiceType::class, 'options' => ['choices' => ',']]]], + InvalidOptionsException::class, + 'The option "choices" with value "," is expected to be of type', + ]; + yield 'default value that cannot be transformed' => [ + ['context' => ['from' => ['type' => DateType::class, 'default' => 'yesterday', 'options' => ['widget' => 'single_text']]]], + TransformationFailedException::class, + '', + ]; + yield 'missing validator of a field constraint' => [ + ['context' => ['comment' => ['options' => ['constraints' => [[WithoutValidator::class => null]]]]]], + ValidatorException::class, + 'App\Validator\MissingValidator', + ]; + yield 'missing validator of a nested constraint' => [ + ['constraints' => [['Collection' => ['fields' => ['input' => [[WithoutValidator::class => null]]], 'allowExtraFields' => true]]]], + ValidatorException::class, + 'App\Validator\MissingValidator', + ]; + } + + /** + * Without the validator component enabled, the validators of the constraints are not checked. + */ + #[DoesNotPerformAssertions] + public function testWithoutConstraintValidatorFactory(): void + { + $this->createWarmer(['test.process' => $this->rawProcess(['form' => ['context' => ['comment' => ['options' => ['constraints' => [[WithoutValidator::class => null]]]]]]])], false) + ->warmUp(sys_get_temp_dir()); + } + + /** + * @param array> $configurations + */ + private function createWarmer(array $configurations, bool $withValidatorFactory = true): LaunchFormsCacheWarmer + { + $registry = new ProcessConfigurationRegistry($configurations, 'stop'); + $manager = new ProcessConfigurationsManager($registry); + $formFactory = Forms::createFormFactoryBuilder() + ->addExtension(new ValidatorExtension(Validation::createValidator())) + ->addType(new LaunchType($registry, $manager)) + ->getFormFactory(); + + return new LaunchFormsCacheWarmer($manager, $formFactory, $withValidatorFactory ? new ContainerConstraintValidatorFactory(new Container()) : null); + } + + /** + * Raw configuration of a process with a single task, as the process bundle configuration would give it. + * + * @param array $ui + * + * @return array + */ + private function rawProcess(array $ui = [], bool $public = true): array + { + return [ + 'options' => [] === $ui ? [] : ['ui' => $ui], + 'entry_point' => 'data', + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => $public, + 'tasks' => [ + 'data' => [ + 'service' => '@CleverAge\ProcessBundle\Task\DummyTask', + 'options' => [], + 'description' => '', + 'help' => '', + 'outputs' => [], + 'errors' => [], + 'error_outputs' => [], + 'error_strategy' => null, + 'log_level' => null, + ], + ], + ]; + } + + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } +} diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index 98a3c87..dd5d075 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\ProcessDashboardController; @@ -29,6 +30,8 @@ use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Http\Model\HttpProcessExecution; use CleverAge\UiProcessBundle\Http\ValueResolver\HttpProcessExecuteValueResolver; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; @@ -61,6 +64,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessDashboardController::class)] #[UsesClass(ProcessExecutionCrudController::class)] diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 6f3ccde..9ab90f5 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\Process\LaunchAction; @@ -73,6 +74,7 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Functional/LogRecordCrudTest.php b/tests/Functional/LogRecordCrudTest.php index 86afc3d..6ca2a6c 100644 --- a/tests/Functional/LogRecordCrudTest.php +++ b/tests/Functional/LogRecordCrudTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\Admin\Field\ContextField; use CleverAge\UiProcessBundle\Admin\Field\LogLevelField; use CleverAge\UiProcessBundle\Admin\Filter\LogProcessFilter; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\ProcessDashboardController; @@ -31,6 +32,8 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -65,6 +68,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/ProcessExecutionCrudTest.php b/tests/Functional/ProcessExecutionCrudTest.php index 678a9ec..1827e2c 100644 --- a/tests/Functional/ProcessExecutionCrudTest.php +++ b/tests/Functional/ProcessExecutionCrudTest.php @@ -16,6 +16,7 @@ use CleverAge\UiProcessBundle\Admin\Field\ContextField; use CleverAge\UiProcessBundle\Admin\Field\EnumField; use CleverAge\UiProcessBundle\Admin\Filter\ProcessExecutionDurationFilter; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\ProcessDashboardController; @@ -32,6 +33,8 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -71,6 +74,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/ProcessExecutionNotificationTest.php b/tests/Functional/ProcessExecutionNotificationTest.php index a02812b..ba39ee1 100644 --- a/tests/Functional/ProcessExecutionNotificationTest.php +++ b/tests/Functional/ProcessExecutionNotificationTest.php @@ -14,6 +14,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; use CleverAge\ProcessBundle\Manager\ProcessManager; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; @@ -23,6 +24,8 @@ use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -45,6 +48,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/ProcessListTest.php b/tests/Functional/ProcessListTest.php index 0e7e966..4ed80f0 100644 --- a/tests/Functional/ProcessListTest.php +++ b/tests/Functional/ProcessListTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\Process\ListAction; @@ -27,6 +28,8 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -60,6 +63,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] @@ -120,6 +126,19 @@ public function testListDoesNotResolveTheLaunchForms(): void self::assertSame([], $deprecations); } + /** + * The launch forms of the test application are built with its form factory, its type extensions and its + * constraint validators, each time the container is built. + */ + public function testLaunchFormsAreCheckedWhenTheContainerIsBuilt(): void + { + $warmer = static::getContainer()->get(LaunchFormsCacheWarmer::class); + self::assertInstanceOf(LaunchFormsCacheWarmer::class, $warmer); + self::assertFalse($warmer->isOptional()); + + self::assertSame([], $warmer->warmUp(sys_get_temp_dir())); + } + public function testUsersMenuIsReservedToAdmins(): void { $this->login(); diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index d2f95dd..8090e01 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -14,6 +14,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; use CleverAge\UiProcessBundle\Admin\Field\EnumField; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\ProcessDashboardController; @@ -29,6 +30,7 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -68,6 +70,8 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(UserCrudController::class)] diff --git a/tests/Functional/PurgeLogsCommandTest.php b/tests/Functional/PurgeLogsCommandTest.php index b838b0f..1b50a6d 100644 --- a/tests/Functional/PurgeLogsCommandTest.php +++ b/tests/Functional/PurgeLogsCommandTest.php @@ -14,6 +14,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Command\PurgeLogsCommand; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; @@ -24,6 +25,8 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; @@ -39,6 +42,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/SecurityTest.php b/tests/Functional/SecurityTest.php index 54525eb..7894d16 100644 --- a/tests/Functional/SecurityTest.php +++ b/tests/Functional/SecurityTest.php @@ -17,6 +17,7 @@ use CleverAge\UiProcessBundle\Admin\Field\EnumField; use CleverAge\UiProcessBundle\Admin\Filter\LogProcessFilter; use CleverAge\UiProcessBundle\Admin\Filter\ProcessExecutionDurationFilter; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\Process\LaunchAction; @@ -34,6 +35,8 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -76,6 +79,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Functional/UserCrudTest.php b/tests/Functional/UserCrudTest.php index 72b9ab4..bec689e 100644 --- a/tests/Functional/UserCrudTest.php +++ b/tests/Functional/UserCrudTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Functional; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\Controller\Admin\LogRecordCrudController; use CleverAge\UiProcessBundle\Controller\Admin\ProcessDashboardController; @@ -26,6 +27,8 @@ use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; @@ -52,6 +55,9 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php index 1f2ee46..3ea9490 100644 --- a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php +++ b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Manager; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; @@ -23,6 +24,8 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -50,6 +53,9 @@ #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php index c00bc6b..c392750 100644 --- a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php +++ b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Monolog\Handler; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; @@ -23,6 +24,8 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -45,6 +48,9 @@ #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Repository/ProcessExecutionRepositoryTest.php b/tests/Repository/ProcessExecutionRepositoryTest.php index ff976aa..6e2dd40 100644 --- a/tests/Repository/ProcessExecutionRepositoryTest.php +++ b/tests/Repository/ProcessExecutionRepositoryTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Repository; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; @@ -22,6 +23,8 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; @@ -40,6 +43,9 @@ #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Repository/ProcessScheduleRepositoryTest.php b/tests/Repository/ProcessScheduleRepositoryTest.php index 7739a9d..c3936f4 100644 --- a/tests/Repository/ProcessScheduleRepositoryTest.php +++ b/tests/Repository/ProcessScheduleRepositoryTest.php @@ -13,6 +13,7 @@ namespace CleverAge\UiProcessBundle\Tests\Repository; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; @@ -22,6 +23,8 @@ use CleverAge\UiProcessBundle\Entity\ProcessSchedule; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Repository\ProcessScheduleRepository; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; @@ -38,6 +41,9 @@ #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/StopChecker/ProcessExecutionStopCheckerTest.php b/tests/StopChecker/ProcessExecutionStopCheckerTest.php index a656a30..99615b5 100644 --- a/tests/StopChecker/ProcessExecutionStopCheckerTest.php +++ b/tests/StopChecker/ProcessExecutionStopCheckerTest.php @@ -16,6 +16,7 @@ use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; use CleverAge\ProcessBundle\Model\ProcessHistory; use CleverAge\ProcessBundle\Model\ProcessState; +use CleverAge\UiProcessBundle\CacheWarmer\LaunchFormsCacheWarmer; use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; @@ -24,6 +25,8 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; +use CleverAge\UiProcessBundle\Form\Type\LaunchType; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Manager\ProcessExecutionManager; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; @@ -43,6 +46,9 @@ #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(CheckLaunchFormsPass::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] From cdc331bd6fc6e4db829aa87f55a443e168c2aeeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:39:25 +0200 Subject: [PATCH 15/17] #160 - Declared context fields are not required by default Review of #168: the declared context fields followed the Symfony default (required: true), so a CheckboxType had to be checked (the dry_run example of #160 needed an explicit required: false). - LaunchType: the declared context fields are not required by default, an explicit "required" option of the field takes precedence (the input is still required when the process defines an entry_point). - A select that is not required has an empty choice: the Delimiter examples (reference, cookbook, upgrade guide) set required: true. - Tests, reference documentation and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- UPGRADE.md | 1 + docs/cookbooks/form_file_upload.md | 1 + docs/reference/02-process_ui_options.md | 11 +++++++---- src/Form/Type/LaunchType.php | 5 +++-- tests/App/config/packages.yaml | 1 - tests/Form/Type/LaunchTypeTest.php | 11 ++++++----- tests/Functional/LaunchActionTest.php | 3 ++- 8 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f65a7c1..83197ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`; a value that cannot be converted is reported as an error of its field) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation; then the launch forms are built with the form factory of the application each time the container is built (internal `LaunchFormsCacheWarmer`, not optional): invalid form type options, default values or constraint validators fail there. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), the declared context fields being not required by default; whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`; a value that cannot be converted is reported as an error of its field) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation; then the launch forms are built with the form factory of the application each time the container is built (internal `LaunchFormsCacheWarmer`, not optional): invalid form type options, default values or constraint validators fail there. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index 070be44..5668cfe 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -74,6 +74,7 @@ options: default: ',' options: choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + required: true ``` - Mixing a deprecated option and its replacement (e.g. `entrypoint_type` and `form.input`, or `constraints` and diff --git a/docs/cookbooks/form_file_upload.md b/docs/cookbooks/form_file_upload.md index 78f6681..dff63f1 100644 --- a/docs/cookbooks/form_file_upload.md +++ b/docs/cookbooks/form_file_upload.md @@ -35,6 +35,7 @@ clever_age_process: options: label: Delimiter choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + required: true # a select that is not required has an empty choice tasks: log_file: service: '@CleverAge\ProcessBundle\Task\Reporting\LoggerTask' diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 61e9c3e..ab81d9e 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -133,10 +133,10 @@ options: options: label: Delimiter choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + required: true dry_run: type: Symfony\Component\Form\Extension\Core\Type\CheckboxType - default: false - options: { required: false } + default: false # not required by default: it can be left unchecked comment: ~ # a TextType field without default value allow_extra_context: true constraints: [] @@ -179,8 +179,11 @@ The `extensions` option of the `File` constraint also checks the detected MIME t often detected as `text/plain` (semicolon delimiter, single column, few lines): list the accepted MIME types per extension (`extensions: { csv: ['text/csv', 'text/plain'] }`). -`options.required` is only the HTML `required` attribute (Symfony default: `true`, including for a `CheckboxType`, which -must then be checked): the server-side validation relies on `options.constraints` (e.g. `NotBlank`). +`options.required` is only the HTML `required` attribute: the server-side validation relies on `options.constraints` +(e.g. `NotBlank`). The declared context fields are not required by default (unlike the Symfony default, a required +`CheckboxType` must be checked, and a select that is not required has an empty choice): set `options.required: true` +to mark one as required. The input is required when the +process defines an `entry_point` (see [launch form](#launch-form)). The `data`, `mapped`, `property_path` and `inherit_data` options are not supported (they break the mapping of the context and of the default values): use `default` for the initial value. diff --git a/src/Form/Type/LaunchType.php b/src/Form/Type/LaunchType.php index 25a403c..1d0b7ca 100644 --- a/src/Form/Type/LaunchType.php +++ b/src/Form/Type/LaunchType.php @@ -57,10 +57,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ); // Not "required: false": a child of a form that is not required is never required (HTML attribute), the - // declared fields follow their own "required" option; the free context pairs are not required + // declared fields follow their own "required" option, not required by default (a required checkbox must be + // checked); the free context pairs are not required $context = $builder->create('context', FormType::class); foreach ($form['context'] as $key => $field) { - $context->add($key, $field['type'], $field['options']); + $context->add($key, $field['type'], $field['options'] + ['required' => false]); } if ($form['allow_extra_context']) { $context->add( diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index c468e1a..d49c4fa 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -108,7 +108,6 @@ clever_age_process: choices: { 'Comma': ',', 'Semicolon': ';' } dry_run: type: Symfony\Component\Form\Extension\Core\Type\CheckboxType - options: { required: false } tasks: data: service: '@CleverAge\ProcessBundle\Task\DummyTask' diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php index 461ac4c..f58988f 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -66,9 +66,9 @@ protected function getExtensions(): array 'delimiter' => [ 'type' => ChoiceType::class, 'default' => ',', - 'options' => ['label' => 'Delimiter', 'choices' => ['Comma' => ',', 'Semicolon' => ';']], + 'options' => ['label' => 'Delimiter', 'choices' => ['Comma' => ',', 'Semicolon' => ';'], 'required' => true], ], - 'dry_run' => ['type' => CheckboxType::class, 'default' => false, 'options' => ['required' => false]], + 'dry_run' => ['type' => CheckboxType::class, 'default' => false], 'limit' => ['type' => IntegerType::class, 'options' => ['constraints' => [['Positive' => null]]]], ], ]]], 'data'), @@ -288,9 +288,10 @@ public function testRequiredOptionOfTheDeclaredFields(): void $context = $view->children['context']; self::assertFalse($context->vars['required'], 'The context group is not rendered as required'); - self::assertTrue($context->children['delimiter']->vars['required'], 'Symfony default'); - self::assertTrue($context->children['limit']->vars['required'], 'Symfony default'); - self::assertFalse($context->children['dry_run']->vars['required'], 'The required option of the field is configured'); + self::assertTrue($context->children['delimiter']->vars['required'], 'The required option of the field is configured'); + // Not the Symfony default: a required checkbox must be checked + self::assertFalse($context->children['limit']->vars['required'], 'Not required by default'); + self::assertFalse($context->children['dry_run']->vars['required'], 'Not required by default'); self::assertFalse($context->children['extra']->vars['required'], 'The free context pairs are not required'); } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 9ab90f5..77a65ef 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -524,7 +524,8 @@ public function testPureEnumField(): void $form = $crawler->selectButton('Launch')->form(); /** @var array> $values */ $values = $form->getPhpValues(); - $values['launch']['context'] = ['choice' => $crawler->filter('select[name="launch[context][choice]"] option')->attr('value')]; + // The first option is the empty choice of a field that is not required + $values['launch']['context'] = ['choice' => $crawler->filter('select[name="launch[context][choice]"] option:not([value=""])')->attr('value')]; $this->client->request($form->getMethod(), $form->getUri(), $values); self::assertResponseRedirects(); From 728d0664ea787673f21ba4e558035da8b2cc9cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 12:39:40 +0200 Subject: [PATCH 16/17] #160 - Split the CHANGELOG entry into bullets Review of #168: the entry was a single ~2000-character paragraph. It is split into bullets, and only describes the scope of #160. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83197ff..fe512e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ Latest * [#172](https://github.com/cleverage/ui-process-bundle/issues/172) Prefix the route names of the bundle with `cleverage_ui_process_`, as required by the Symfony best practices for bundles: `cleverage_ui_process_login`, `cleverage_ui_process_logout`, `cleverage_ui_process_list`, `cleverage_ui_process_launch`, `cleverage_ui_process_http_execute`, and the dashboard `cleverage_ui_process`, so that the routes generated by EasyAdmin for the CRUD pages are `cleverage_ui_process__`. The former names (`process`, `process__`, `process_login`, `process_logout`, `process_list`, `process_launch`, `http_process_execute`) are deprecated route aliases (new `config/routes/deprecated_aliases.yaml`, loaded by the documented import, and internal `DeprecatedEasyAdminRouteAliasLoader` for the routes generated by EasyAdmin), removed in 4.0; `?routeName=` URLs keep working. Document that `easyadmin.routes` generates the routes of every dashboard. Update documentation and UPGRADE.md, add tests. * [#166](https://github.com/cleverage/ui-process-bundle/issues/166) Doctrine ORM 2: the entities have readonly properties (since v3.2), which the legacy proxies of ORM 2 cannot handle (`Cannot unset readonly property ... from scope Proxies\__CG__\...` when an entity proxy is loaded). The container compilation now fails with an explicit message when ORM 2 is used without `doctrine.orm.enable_lazy_ghost_objects: true` (new internal `CheckDoctrineLazyGhostObjectsPass`); nothing changes with ORM 3. Update documentation, add tests. * [#164](https://github.com/cleverage/ui-process-bundle/issues/164) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`: the functions of PHP 8.3 to 8.5 (`json_validate()`, `array_find()`, `array_first()`...) can be used while supporting PHP 8.2 (see `CONTRIBUTING.md`). Rector is configured for PHP 8.2 (was 8.5, which allowed it to introduce PHP 8.3+ functions without polyfill) with the `PHP_POLYFILLS` set (`withPhpSets()` without argument), it does not change the code. `array_last()` replaces `end()` in a test. Tests: lazy ghost proxies with Doctrine ORM 2 (now installed by the lowest dependencies job), as configured by the Symfony recipe: the legacy proxies cannot unset the readonly properties of the entities. -* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, the FQCN of a form type; `options`, the form type options, with `constraints`; `default`, the initial value, an enum case given with `!php/enum` for an `EnumType`), the declared context fields being not required by default; whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation (internal `ContextValueNormalizer`; a value that cannot be converted is reported as an error of its field) (enums, dates, date intervals, `Stringable` objects; files are refused in the context). The `form` option is resolved by the new internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`, only on the launch page: `getUiOptions()` does not resolve it (the process list and the executions list do not depend on the launch form). New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The launch form of the processes declaring the `form` UI option is checked at compile time (internal `CheckLaunchFormsPass`): an invalid launch form fails the container compilation; then the launch forms are built with the form factory of the application each time the container is built (internal `LaunchFormsCacheWarmer`, not optional): invalid form type options, default values or constraint validators fail there. The launch action resolves the launch form once (internal `launch_form` option of `LaunchType`). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the launch form of such a process is displayed. Add documentation, an upgrade guide and tests. +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: full control over the fields of the launch form through the new `form` UI option. + * `form.input` and `form.context.`: `type` (the FQCN of a form type), `options` (the form type options, with `constraints`) and `default` (the initial value; an enum case given with `!php/enum` for an `EnumType`). The declared context fields are not required by default. + * `form.allow_extra_context` (default `true`): free key/value context pairs for the undeclared keys. A free pair whose key is a declared field is reported as a validation error (new `reserved_keys` option of `ProcessContextType`). + * `form.constraints`: the constraints of the whole form. + * The typed values of the form are converted into process values after the validation (enums, dates, date intervals, `Stringable` objects); a value that cannot be converted is reported as an error of its field. + * The launch forms are checked each time the container is built: their structure at compile time (internal `CheckLaunchFormsPass`), then the form type options, the default values and the constraint validators with the form factory of the application (internal `LaunchFormsCacheWarmer`). + * The `form` option is only resolved by the launch page (internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`): `getUiOptions()` does not resolve it, the process list and the executions list do not depend on the launch form. + * The free context pairs of the launch form are named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`): the `context` child of `LaunchType` is a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`). The duplicated keys of `default.context` are merged (the last one wins). + * Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0; their deprecation is triggered each time the launch form of such a process is displayed. + * Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). From 8883fe1b634c7ca798155309fdff7ae34a023cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Fri, 16 Oct 2026 13:06:39 +0200 Subject: [PATCH 17/17] #160 - Remove CheckLaunchFormsPass: the launch forms are checked by LaunchFormsCacheWarmer LaunchFormsCacheWarmer runs after each build of the container and resolves each launch form (same structural checks as the compiler pass: types, keys, file fields, enum defaults, constraints, conflicts with the deprecated options) before building it with the form factory of the application: the compiler pass is redundant. - Remove CheckLaunchFormsPass, its registration and its tests, and the metadata of the tests booting the test kernel. - LaunchFormsCacheWarmer docblock, reference documentation and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/reference/02-process_ui_options.md | 10 +- src/CacheWarmer/LaunchFormsCacheWarmer.php | 11 +- src/CleverAgeUiProcessBundle.php | 2 - .../Compiler/CheckLaunchFormsPass.php | 91 ------- tests/CleverAgeUiProcessBundleTest.php | 14 -- .../Compiler/CheckLaunchFormsPassTest.php | 231 ------------------ tests/Functional/HttpProcessExecuteTest.php | 2 - tests/Functional/LaunchActionTest.php | 2 - tests/Functional/LogRecordCrudTest.php | 2 - tests/Functional/ProcessExecutionCrudTest.php | 2 - .../ProcessExecutionNotificationTest.php | 2 - tests/Functional/ProcessListTest.php | 2 - tests/Functional/ProcessScheduleCrudTest.php | 2 - tests/Functional/PurgeLogsCommandTest.php | 2 - tests/Functional/SecurityTest.php | 2 - tests/Functional/UserCrudTest.php | 2 - ...ocessExecutionManagerEntityManagerTest.php | 2 - ...octrineProcessHandlerEntityManagerTest.php | 2 - .../ProcessExecutionRepositoryTest.php | 2 - .../ProcessScheduleRepositoryTest.php | 2 - .../ProcessExecutionStopCheckerTest.php | 2 - 22 files changed, 11 insertions(+), 380 deletions(-) delete mode 100644 src/DependencyInjection/Compiler/CheckLaunchFormsPass.php delete mode 100644 tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fe512e5..b43bcdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Latest * `form.allow_extra_context` (default `true`): free key/value context pairs for the undeclared keys. A free pair whose key is a declared field is reported as a validation error (new `reserved_keys` option of `ProcessContextType`). * `form.constraints`: the constraints of the whole form. * The typed values of the form are converted into process values after the validation (enums, dates, date intervals, `Stringable` objects); a value that cannot be converted is reported as an error of its field. - * The launch forms are checked each time the container is built: their structure at compile time (internal `CheckLaunchFormsPass`), then the form type options, the default values and the constraint validators with the form factory of the application (internal `LaunchFormsCacheWarmer`). + * The launch forms are checked each time the container is built (internal `LaunchFormsCacheWarmer`): they are resolved, then built with the form factory of the application (form type options, default values, constraint validators). * The `form` option is only resolved by the launch page (internal `ProcessConfigurationsManager::getLaunchFormOptions()` and `LaunchFormOptionsResolver`): `getUiOptions()` does not resolve it, the process list and the executions list do not depend on the launch form. * The free context pairs of the launch form are named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`): the `context` child of `LaunchType` is a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`). The duplicated keys of `default.context` are merged (the last one wins). * Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0; their deprecation is triggered each time the launch form of such a process is displayed. diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index ab81d9e..613c74d 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -44,12 +44,10 @@ Every key is optional: a process without `options.ui` is displayed and launched keys, or invalid values, raise an options resolver error when the process list is displayed. The `form` option (and its interaction with the deprecated options) is not resolved by the process list. It is checked each time the container is built (`cache:clear`, `cache:warmup`, or the automatic rebuild in debug mode), with a message naming the -process: -- its structure when the container is compiled (types, keys, file fields, default values of the enum fields, - constraints, conflicts with the deprecated options); -- then the launch form is built with the form factory of the application: the options of the form types (with the - type extensions of the application), the default values of the fields, and the validators of the constraints - (nested constraints included). +process: the launch form is resolved (types, keys, file fields, default values of the enum fields, constraints, +conflicts with the deprecated options), then built with the form factory of the application (the options of the form +types with the type extensions of the application, the default values of the fields, and the validators of the +constraints, nested constraints included). It is resolved again when the launch form of the process is displayed. diff --git a/src/CacheWarmer/LaunchFormsCacheWarmer.php b/src/CacheWarmer/LaunchFormsCacheWarmer.php index 50f405b..4f04ef3 100644 --- a/src/CacheWarmer/LaunchFormsCacheWarmer.php +++ b/src/CacheWarmer/LaunchFormsCacheWarmer.php @@ -25,15 +25,16 @@ use Symfony\Component\Validator\ConstraintValidatorFactoryInterface; /** - * Builds the launch form of the processes declaring the "form" UI option with the form factory of the application, - * each time the container is built (not optional: also run when the container is rebuilt in debug mode), so that an - * invalid launch form fails there instead of on the launch page: + * Resolves the launch form of the processes declaring the "form" UI option, and builds it with the form factory of the + * application, each time the container is built (not optional: also run when the container is rebuilt in debug mode), + * so that an invalid launch form fails there instead of on the launch page: + * - its structure (types, keys, file fields, default values of the enum fields, constraints, conflicts with the + * deprecated options), checked by LaunchFormOptionsResolver; * - the options of the form types (with the type extensions of the application), including the "constraints" option; * - the default values, transformed by the fields; * - the validators of the constraints (of the fields and of the whole form, nested constraints included). * - * CheckLaunchFormsPass checks the structure of the launch forms when the container is compiled, before the services - * exist. The values submitted by the user are converted after the submission. + * The values submitted by the user are converted after the submission. * * @internal */ diff --git a/src/CleverAgeUiProcessBundle.php b/src/CleverAgeUiProcessBundle.php index 7851daf..ebe3bd1 100644 --- a/src/CleverAgeUiProcessBundle.php +++ b/src/CleverAgeUiProcessBundle.php @@ -14,7 +14,6 @@ namespace CleverAge\UiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -32,6 +31,5 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new CheckDoctrineLazyGhostObjectsPass()); - $container->addCompilerPass(new CheckLaunchFormsPass()); } } diff --git a/src/DependencyInjection/Compiler/CheckLaunchFormsPass.php b/src/DependencyInjection/Compiler/CheckLaunchFormsPass.php deleted file mode 100644 index 4d75874..0000000 --- a/src/DependencyInjection/Compiler/CheckLaunchFormsPass.php +++ /dev/null @@ -1,91 +0,0 @@ -hasDefinition(self::REGISTRY_ID)) { - return; - } - $configurations = $container->getDefinition(self::REGISTRY_ID)->getArgument(0); - if (!\is_array($configurations)) { - return; - } - - $parameterBag = $container->getParameterBag(); - foreach ($configurations as $processCode => $configuration) { - $ui = \is_array($configuration) && \is_array($configuration['options'] ?? null) ? $configuration['options']['ui'] ?? null : null; - if (!\is_array($ui) || !\array_key_exists('form', $ui)) { - continue; - } - $usedEnvs = []; - $container->resolveEnvPlaceholders($ui, null, $usedEnvs); - if ([] !== $usedEnvs) { - continue; - } - /** @var array $ui */ - $ui = $parameterBag->unescapeValue($parameterBag->resolveValue($ui)); - - $this->checkLaunchForm((string) $processCode, $ui); - } - } - - /** - * @param array $ui - */ - private function checkLaunchForm(string $processCode, array $ui): void - { - // Only the UI options of the process: its tasks and its other options are not concerned - $registry = new ProcessConfigurationRegistry([$processCode => [ - 'options' => ['ui' => $ui], - 'entry_point' => null, - 'end_point' => null, - 'description' => '', - 'help' => '', - 'public' => true, - 'tasks' => [], - ]], 'stop'); - - // The deprecations of the launch form are triggered when it is displayed - set_error_handler(static fn (): bool => true, \E_USER_DEPRECATED); - try { - (new ProcessConfigurationsManager($registry))->getLaunchFormOptions($processCode); - } catch (\Throwable $e) { - throw new InvalidArgumentException(\sprintf('The launch form ("form" UI option) of the process "%s" is invalid: %s', $processCode, $e->getMessage()), 0, $e); - } finally { - restore_error_handler(); - } - } -} diff --git a/tests/CleverAgeUiProcessBundleTest.php b/tests/CleverAgeUiProcessBundleTest.php index dd58f3a..b900fa2 100644 --- a/tests/CleverAgeUiProcessBundleTest.php +++ b/tests/CleverAgeUiProcessBundleTest.php @@ -16,7 +16,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; @@ -27,7 +26,6 @@ #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] -#[UsesClass(CheckLaunchFormsPass::class)] class CleverAgeUiProcessBundleTest extends TestCase { public function testPathIsTheBundleRootDirectory(): void @@ -58,16 +56,4 @@ public function testDoctrineLazyGhostObjectsCheckIsRegistered(): void ); self::assertCount(1, $passes); } - - public function testLaunchFormsCheckIsRegistered(): void - { - $container = new ContainerBuilder(); - (new CleverAgeUiProcessBundle())->build($container); - - $passes = array_filter( - $container->getCompilerPassConfig()->getPasses(), - static fn (object $pass): bool => $pass instanceof CheckLaunchFormsPass, - ); - self::assertCount(1, $passes); - } } diff --git a/tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php b/tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php deleted file mode 100644 index 192f270..0000000 --- a/tests/DependencyInjection/Compiler/CheckLaunchFormsPassTest.php +++ /dev/null @@ -1,231 +0,0 @@ - $ui - */ - #[DataProvider('provideValidUiOptions')] - public function testValidLaunchForms(array $ui): void - { - $container = $this->createContainer(['test.process' => $this->rawProcess($ui)]); - - $deprecations = $this->collectDeprecations(static fn () => (new CheckLaunchFormsPass())->process($container)); - - // The deprecations are triggered when the launch form is displayed - self::assertSame([], $deprecations); - } - - /** - * @return iterable}> - */ - public static function provideValidUiOptions(): iterable - { - yield 'no UI options' => [[]]; - yield 'form' => [[ - 'ui_launch_mode' => 'form', - 'form' => [ - 'input' => ['type' => FileType::class], - 'context' => [ - 'delimiter' => ['type' => ChoiceType::class, 'default' => ',', 'options' => ['choices' => [',' => ',', ';' => ';']]], - 'enum' => ['type' => EnumType::class, 'default' => StringBackedEnum::Comma, 'options' => ['class' => StringBackedEnum::class]], - ], - 'allow_extra_context' => false, - 'constraints' => [['NotNull' => null]], - ], - ]]; - yield 'form with deprecated options' => [['form' => ['context' => ['delimiter' => null]], 'default' => ['context' => [['key' => 'foo', 'value' => 'bar']]]]]; - // Not checked: the process has no "form" option (the deprecated options are checked as before 3.3) - yield 'invalid deprecated option without form' => [['entrypoint_type' => 'csv']]; - } - - /** - * @param array $ui - */ - #[DataProvider('provideInvalidUiOptions')] - public function testInvalidLaunchForms(array $ui, string $message): void - { - $container = $this->createContainer(['valid.process' => $this->rawProcess(), 'test.process' => $this->rawProcess($ui)]); - - try { - (new CheckLaunchFormsPass())->process($container); - self::fail('An invalid launch form is expected to fail the compilation.'); - } catch (InvalidArgumentException $e) { - self::assertSame(\sprintf('The launch form ("form" UI option) of the process "test.process" is invalid: %s', $message), $e->getMessage()); - self::assertInstanceOf(InvalidOptionsException::class, $e->getPrevious()); - } - } - - /** - * @return iterable, string}> - */ - public static function provideInvalidUiOptions(): iterable - { - yield 'unknown type' => [ - ['form' => ['input' => ['type' => 'file']]], - 'The type "file" of the launch form field "form.input" of the process "test.process" must be the FQCN of a form type, the class does not exist.', - ]; - yield 'file in the context' => [ - ['form' => ['context' => ['data' => ['type' => FileType::class]]]], - 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', - ]; - yield 'scalar enum default' => [ - ['form' => ['context' => ['enum' => ['type' => EnumType::class, 'default' => ',', 'options' => ['class' => StringBackedEnum::class]]]]], - \sprintf('The default value of the launch form field "form.context.enum" of the process "test.process" must be a case of the enum "%s" (with "multiple", a list of cases), "string" given: use the "!php/enum" YAML tag, e.g. "default: !php/enum App\Enum\Status::Active".', StringBackedEnum::class), - ]; - yield 'conflict with a deprecated option' => [ - ['entrypoint_type' => 'file', 'form' => ['input' => ['type' => FileType::class]]], - 'The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.', - ]; - yield 'not an array' => [['form' => 'file'], 'The "form" UI option of the process "test.process" must be an array, "string" given.']; - } - - /** - * Only the UI options are checked: the tasks and the other options of the process are not concerned. - */ - #[DoesNotPerformAssertions] - public function testOtherOptionsAreNotChecked(): void - { - /** @var array $process */ - $process = array_replace_recursive($this->rawProcess(['form' => ['context' => ['delimiter' => null]]]), [ - 'options' => ['notification' => ['enabled' => 'yes']], - 'tasks' => ['data' => ['outputs' => ['missing']]], - ]); - $container = $this->createContainer(['test.process' => $process]); - - (new CheckLaunchFormsPass())->process($container); - } - - public function testParametersAreResolved(): void - { - $container = $this->createContainer(['test.process' => $this->rawProcess(['form' => ['input' => ['type' => '%input_type%']]])]); - $container->setParameter('input_type', 'file'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The type "file" of the launch form field "form.input"'); - - (new CheckLaunchFormsPass())->process($container); - } - - /** - * A launch form using an environment variable is resolved when it is displayed. - */ - public function testEnvironmentVariablesAreNotResolved(): void - { - $container = new ContainerBuilder(); - // The placeholder of an environment variable, as resolved by the configuration of the process bundle - $type = $container->getParameterBag()->get('env(INPUT_TYPE)'); - self::assertIsString($type); - $container->setDefinition(CheckLaunchFormsPass::REGISTRY_ID, new Definition(\stdClass::class, [ - ['test.process' => $this->rawProcess(['form' => ['input' => ['type' => $type]]])], - 'stop', - [], - ])); - - (new CheckLaunchFormsPass())->process($container); - } - - #[DoesNotPerformAssertions] - public function testWithoutTheProcessBundle(): void - { - (new CheckLaunchFormsPass())->process(new ContainerBuilder()); - } - - /** - * @param array> $configurations - */ - private function createContainer(array $configurations): ContainerBuilder - { - $container = new ContainerBuilder(); - $container->setDefinition(CheckLaunchFormsPass::REGISTRY_ID, new Definition(\stdClass::class, [$configurations, 'stop', []])); - - return $container; - } - - /** - * Raw configuration of a process with a single task, as the process bundle configuration would give it. - * - * @param array $ui - * - * @return array - */ - private function rawProcess(array $ui = []): array - { - return [ - 'options' => [] === $ui ? [] : ['ui' => $ui], - 'entry_point' => null, - 'end_point' => null, - 'description' => '', - 'help' => '', - 'public' => true, - 'tasks' => [ - 'data' => [ - 'service' => '@CleverAge\ProcessBundle\Task\DummyTask', - 'options' => [], - 'description' => '', - 'help' => '', - 'outputs' => [], - 'errors' => [], - 'error_outputs' => [], - 'error_strategy' => null, - 'log_level' => null, - ], - ], - ]; - } - - /** - * @return list - */ - private function collectDeprecations(\Closure $callback): array - { - $deprecations = []; - set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { - $deprecations[] = $errstr; - - return true; - }, \E_USER_DEPRECATED); - try { - $callback(); - } finally { - restore_error_handler(); - } - - return $deprecations; - } -} diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index dd5d075..b47496b 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -23,7 +23,6 @@ use CleverAge\UiProcessBundle\Controller\ProcessExecuteController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Entity\User; @@ -63,7 +62,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 77a65ef..3da2f7a 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -24,7 +24,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; @@ -73,7 +72,6 @@ #[UsesClass(ProcessExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] diff --git a/tests/Functional/LogRecordCrudTest.php b/tests/Functional/LogRecordCrudTest.php index 6ca2a6c..63a8103 100644 --- a/tests/Functional/LogRecordCrudTest.php +++ b/tests/Functional/LogRecordCrudTest.php @@ -25,7 +25,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; @@ -67,7 +66,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/ProcessExecutionCrudTest.php b/tests/Functional/ProcessExecutionCrudTest.php index 1827e2c..35108f5 100644 --- a/tests/Functional/ProcessExecutionCrudTest.php +++ b/tests/Functional/ProcessExecutionCrudTest.php @@ -25,7 +25,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; @@ -73,7 +72,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/ProcessExecutionNotificationTest.php b/tests/Functional/ProcessExecutionNotificationTest.php index ba39ee1..04e950a 100644 --- a/tests/Functional/ProcessExecutionNotificationTest.php +++ b/tests/Functional/ProcessExecutionNotificationTest.php @@ -18,7 +18,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\Event\ProcessExecutionEndedEvent; @@ -47,7 +46,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/ProcessListTest.php b/tests/Functional/ProcessListTest.php index 4ed80f0..b648268 100644 --- a/tests/Functional/ProcessListTest.php +++ b/tests/Functional/ProcessListTest.php @@ -23,7 +23,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; @@ -62,7 +61,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index 8090e01..ef1c610 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -23,7 +23,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; use CleverAge\UiProcessBundle\Entity\ProcessSchedule; @@ -69,7 +68,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] diff --git a/tests/Functional/PurgeLogsCommandTest.php b/tests/Functional/PurgeLogsCommandTest.php index 1b50a6d..5aa58f2 100644 --- a/tests/Functional/PurgeLogsCommandTest.php +++ b/tests/Functional/PurgeLogsCommandTest.php @@ -19,7 +19,6 @@ use CleverAge\UiProcessBundle\Command\PurgeLogsCommand; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; @@ -41,7 +40,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/SecurityTest.php b/tests/Functional/SecurityTest.php index 7894d16..14451a8 100644 --- a/tests/Functional/SecurityTest.php +++ b/tests/Functional/SecurityTest.php @@ -30,7 +30,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; @@ -78,7 +77,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Functional/UserCrudTest.php b/tests/Functional/UserCrudTest.php index bec689e..97fbf72 100644 --- a/tests/Functional/UserCrudTest.php +++ b/tests/Functional/UserCrudTest.php @@ -22,7 +22,6 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; @@ -54,7 +53,6 @@ #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php index 3ea9490..671b05f 100644 --- a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php +++ b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php @@ -17,7 +17,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; @@ -52,7 +51,6 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php index c392750..3d53526 100644 --- a/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php +++ b/tests/Monolog/Handler/DoctrineProcessHandlerEntityManagerTest.php @@ -17,7 +17,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\UiProcessBundle\Entity\LogRecord; @@ -47,7 +46,6 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Repository/ProcessExecutionRepositoryTest.php b/tests/Repository/ProcessExecutionRepositoryTest.php index 6e2dd40..a06ef1e 100644 --- a/tests/Repository/ProcessExecutionRepositoryTest.php +++ b/tests/Repository/ProcessExecutionRepositoryTest.php @@ -17,7 +17,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\LogRecord; use CleverAge\UiProcessBundle\Entity\ProcessExecution; @@ -42,7 +41,6 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/Repository/ProcessScheduleRepositoryTest.php b/tests/Repository/ProcessScheduleRepositoryTest.php index c3936f4..fd74dd8 100644 --- a/tests/Repository/ProcessScheduleRepositoryTest.php +++ b/tests/Repository/ProcessScheduleRepositoryTest.php @@ -17,7 +17,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; use CleverAge\UiProcessBundle\Entity\ProcessSchedule; @@ -40,7 +39,6 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)] diff --git a/tests/StopChecker/ProcessExecutionStopCheckerTest.php b/tests/StopChecker/ProcessExecutionStopCheckerTest.php index 99615b5..0bd0030 100644 --- a/tests/StopChecker/ProcessExecutionStopCheckerTest.php +++ b/tests/StopChecker/ProcessExecutionStopCheckerTest.php @@ -20,7 +20,6 @@ use CleverAge\UiProcessBundle\CleverAgeUiProcessBundle; use CleverAge\UiProcessBundle\DependencyInjection\CleverAgeUiProcessExtension; use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckDoctrineLazyGhostObjectsPass; -use CleverAge\UiProcessBundle\DependencyInjection\Compiler\CheckLaunchFormsPass; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\ProcessExecution; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; @@ -45,7 +44,6 @@ #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(LaunchFormOptionsResolver::class)] -#[UsesClass(CheckLaunchFormsPass::class)] #[UsesClass(ProcessContextType::class)] #[UsesClass(LaunchType::class)] #[UsesClass(LaunchFormsCacheWarmer::class)]