diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8f4d4..b43bcdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +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: 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 (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. + * 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..5668cfe 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -22,6 +22,74 @@ 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: Symfony\Component\Form\Extension\Core\Type\FileType + 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: Symfony\Component\Form\Extension\Core\Type\ChoiceType + default: ',' + options: + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + required: true +``` + +- 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 launch form is displayed: expect them + in the deprecation logs until the process is migrated. + ## v3.0 ### Import routes 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/cookbooks/form_file_upload.md b/docs/cookbooks/form_file_upload.md index 2d7da4f..dff63f1 100644 --- a/docs/cookbooks/form_file_upload.md +++ b/docs/cookbooks/form_file_upload.md @@ -17,25 +17,25 @@ clever_age_process: source: CSV upload target: Logs ui_launch_mode: form - entrypoint_type: file - default: + form: + input: + type: Symfony\Component\Form\Extension\Core\Type\FileType + 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: Symfony\Component\Form\Extension\Core\Type\ChoiceType + default: ',' + 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' @@ -62,17 +62,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 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` (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. +- 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 +91,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..613c74d 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: # form type, default: TextType + 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: @@ -31,7 +41,15 @@ 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 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: 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. 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) @@ -45,9 +63,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 @@ -74,23 +93,173 @@ user to the process context, under the `execution_user` key. ### 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 `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. +Form fields +----------- + +`form` declares the fields of the launch form: + +```yaml +options: + ui: + ui_launch_mode: form + form: + input: + type: Symfony\Component\Form\Extension\Core\Type\FileType + 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: Symfony\Component\Form\Extension\Core\Type\ChoiceType + default: ',' + options: + label: Delimiter + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + required: true + dry_run: + type: Symfony\Component\Form\Extension\Core\Type\CheckboxType + default: false # not required by default: it can be left unchecked + 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 `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). | + +### Field spec + +A field spec (`form.input`, `form.context.`) accepts exactly three keys: + +| Key | Type | Default | Description | +|-----------|----------|---------|----------------------------------------------------------------------------------------------------------------| +| `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. | + +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) is reserved. + +`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. 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 + +`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: 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. + +### Default values of the fields + +`default` is the initial value of the field, in the model format of its type: +- 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 `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. + +### File fields + +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 + +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. + +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 +> 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: file fields are only supported for the input | +| `Stringable` object | its string | +| other object | refused | + +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 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 -------------- +*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 +278,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 `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. While a constraint is violated, the process is not launched and the form is displayed again with the violation messages. @@ -122,27 +295,56 @@ messages. options: ui: ui_launch_mode: form - entrypoint_type: file - default: + form: + input: + type: Symfony\Component\Form\Extension\Core\Type\FileType 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 +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 | +|---------------------|-----------------------------------------------------------------------------------------------| +| `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/src/CacheWarmer/LaunchFormsCacheWarmer.php b/src/CacheWarmer/LaunchFormsCacheWarmer.php new file mode 100644 index 0000000..4f04ef3 --- /dev/null +++ b/src/CacheWarmer/LaunchFormsCacheWarmer.php @@ -0,0 +1,124 @@ +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/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index e4a5f39..f83b6ea 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,8 @@ 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\Form\FormError; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -73,26 +75,24 @@ 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['constraints'], + 'constraints' => $launchForm['constraints'], 'process_code' => $processCode, + // Not resolved again by the form type + 'launch_form' => $launchForm, ] ); 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($launchForm['data']); } $form->handleRequest($this->requestStack->getMainRequest()); - if ($form->isSubmitted() && $form->isValid()) { - $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()); @@ -100,8 +100,6 @@ public function __invoke( } /** @var string $processCode */ $processCode = $form->getConfig()->getOption('process_code'); - /** @var array $context */ - $context = $form->get('context')->getData(); $this->dispatch($processCode, $input, $context); $this->addFlash( 'success', @@ -120,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 new file mode 100644 index 0000000..532cb93 --- /dev/null +++ b/src/Form/ContextValueNormalizer.php @@ -0,0 +1,67 @@ +|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. a ChoiceType with scalar choices), or make the class implement \Stringable.', $name, get_debug_type($value))); + } +} diff --git a/src/Form/LaunchFormOptionsResolver.php b/src/Form/LaunchFormOptionsResolver.php new file mode 100644 index 0000000..4c214c6 --- /dev/null +++ b/src/Form/LaunchFormOptionsResolver.php @@ -0,0 +1,327 @@ +>, + * '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)); + } + } + 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> + */ + 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 35551e1..1d0b7ca 100644 --- a/src/Form/Type/LaunchType.php +++ b/src/Form/Type/LaunchType.php @@ -14,18 +14,25 @@ namespace CleverAge\UiProcessBundle\Form\Type; 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\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 LaunchFormOptions from LaunchFormOptionsResolver + * + * @template-extends AbstractType> + */ class LaunchType extends AbstractType { public function __construct( @@ -39,41 +46,83 @@ 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 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( + '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, 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'] + ['required' => false]); + } + 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'])], + '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'); + // @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/Form/Type/ProcessContextType.php b/src/Form/Type/ProcessContextType.php index d95fd19..168f460 100644 --- a/src/Form/Type/ProcessContextType.php +++ b/src/Form/Type/ProcessContextType.php @@ -16,6 +16,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; /** @template-extends AbstractType */ @@ -23,13 +24,24 @@ class ProcessContextType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + $keyConstraints = [new NotBlank()]; + /** @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 + ); + } $builder->add( 'key', null, [ 'label' => 'Context Key', 'attr' => ['placeholder' => 'key'], - 'constraints' => [new NotBlank()], + 'constraints' => $keyConstraints, ] )->add( 'value', @@ -44,5 +56,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void public function configureOptions(OptionsResolver $resolver): void { + $resolver->setDefault('reserved_keys', []); + $resolver->setAllowedTypes('reserved_keys', 'string[]'); } } diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index d2e36d8..51c1130 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -16,6 +16,7 @@ use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; +use CleverAge\UiProcessBundle\Form\LaunchFormOptionsResolver; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; @@ -23,6 +24,8 @@ use Symfony\Component\Validator\Constraint; /** + * @phpstan-import-type LaunchFormOptions from LaunchFormOptionsResolver + * * @phpstan-type UiOptions array{ * 'source': ?string, * 'target': ?string, @@ -30,7 +33,8 @@ * 'entrypoint_type': string, * 'constraints': Constraint[], * 'run': ?bool, - * 'default': array{'input': mixed, 'context': array} + * 'default': array{'input': mixed, 'context': array}, + * 'form': mixed * } * @phpstan-type NotificationOptions array{ * 'enabled': ?bool, @@ -45,8 +49,10 @@ */ final readonly class ProcessConfigurationsManager { - public function __construct(private ProcessConfigurationRegistry $registry) - { + public function __construct( + private ProcessConfigurationRegistry $registry, + private LaunchFormOptionsResolver $launchFormOptionsResolver = new LaunchFormOptionsResolver(), + ) { } /** @return ProcessConfiguration[] */ @@ -62,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 @@ -75,6 +84,26 @@ public function getUiOptions(string $processCode): ?array return $this->resolveOptions($configuration->getOptions())['ui']; } + /** + * 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. + * + * @internal + * + * @return LaunchFormOptions|null + */ + public function getLaunchFormOptions(string $processCode): ?array + { + $uiOptions = $this->getUiOptions($processCode); + if (null === $uiOptions) { + return null; + } + /** @var array $ui */ + $ui = $this->registry->getProcessConfiguration($processCode)->getOptions()['ui'] ?? []; + + return $this->launchFormOptionsResolver->resolve($ui, $uiOptions, $processCode); + } + /** * The "notification" option of the process, null values are inherited from the bundle configuration. * @@ -180,6 +209,8 @@ private function resolveOptions(array $options): array 'constraints' => [], 'run' => null, 'default' => [], + // Resolved by getLaunchFormOptions() + 'form' => [], ] ); $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); 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/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/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/Form/ContextValueNormalizerTest.php b/tests/Form/ContextValueNormalizerTest.php new file mode 100644 index 0000000..2dba254 --- /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. a ChoiceType 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/LaunchFormOptionsResolverTest.php b/tests/Form/LaunchFormOptionsResolverTest.php new file mode 100644 index 0000000..1827463 --- /dev/null +++ b/tests/Form/LaunchFormOptionsResolverTest.php @@ -0,0 +1,735 @@ +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"', + ]; + } + + #[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]]]]); + + 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 ee2ce68..f58988f 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -14,25 +14,34 @@ 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; use PHPUnit\Framework\Attributes\CoversClass; 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)] #[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(ProcessContextType::class)] class LaunchTypeTest extends TypeTestCase { @@ -51,6 +60,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' => ChoiceType::class, + 'default' => ',', + 'options' => ['label' => 'Delimiter', 'choices' => ['Comma' => ',', 'Semicolon' => ';'], 'required' => true], + ], + 'dry_run' => ['type' => CheckboxType::class, 'default' => false], + 'limit' => ['type' => IntegerType::class, '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 +91,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 +124,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 +154,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 +163,172 @@ 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()); + } + + 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'], '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'); + } + + 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 launch form already resolved by the caller is not resolved again. + */ + public function testResolvedLaunchForm(): void + { + $registry = new ProcessConfigurationRegistry(['file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data')], 'stop'); + $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($launchForm); + + $form = null; + $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); + self::assertInstanceOf(FormInterface::class, $form); + self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); + } + + public function testLaunchFormMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process', 'launch_form' => 'form']); } public function testProcessCodeIsRequired(): void @@ -140,6 +337,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..8140c79 100644 --- a/tests/Form/Type/ProcessContextTypeTest.php +++ b/tests/Form/Type/ProcessContextTypeTest.php @@ -20,6 +20,7 @@ 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\NotBlank; use Symfony\Component\Validator\Validation; @@ -51,9 +52,10 @@ 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]); } + self::assertCount(1, (array) $form->get('key')->getConfig()->getOption('constraints')); + self::assertCount(1, (array) $form->get('value')->getConfig()->getOption('constraints')); } public function testSubmitValidData(): void @@ -76,4 +78,50 @@ 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']); + } + + public function testEmptyKey(): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => '', 'value' => 'bar']); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get('key')->getErrors()); + } } 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/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index a291bfd..b47496b 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; @@ -27,6 +28,9 @@ use CleverAge\UiProcessBundle\Entity\User; 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; @@ -57,6 +61,10 @@ #[CoversClass(HttpProcessExecutionAuthenticator::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 95d2a9a..3da2f7a 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; @@ -26,6 +27,8 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; 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; @@ -35,6 +38,9 @@ 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\Tests\Form\Fixtures\StringBackedEnum; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; use CleverAge\UiProcessBundle\Twig\Extension\MD5Extension; use CleverAge\UiProcessBundle\Twig\Extension\ProcessExecutionExtension; @@ -43,6 +49,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 +58,8 @@ #[UsesClass(User::class)] #[UsesClass(LaunchType::class)] #[UsesClass(ProcessContextType::class)] +#[UsesClass(ContextValueNormalizer::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(ProcessExecuteMessage::class)] #[UsesClass(ProcessExecutionRepository::class)] @@ -63,6 +72,7 @@ #[UsesClass(ProcessExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] @@ -103,13 +113,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 +130,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 +199,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 +208,394 @@ 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()); + } + + /** + * 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(); + + $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 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); + } + + /** + * 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(); + + $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(); + // 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(); + $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 error is displayed under the field, 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']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + 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()); + } + + 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()); + } + + /** + * 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 +630,16 @@ 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. + */ + 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/LogRecordCrudTest.php b/tests/Functional/LogRecordCrudTest.php index 3bd0b29..63a8103 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; @@ -29,6 +30,9 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; 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; @@ -61,6 +65,10 @@ #[UsesClass(LogLevelExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 8b89705..35108f5 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; @@ -30,6 +31,9 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; 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; @@ -67,6 +71,10 @@ #[UsesClass(ProcessExecutionExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 a1fc04c..04e950a 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; @@ -21,6 +22,9 @@ use CleverAge\UiProcessBundle\Entity\ProcessExecution; 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; @@ -41,6 +45,10 @@ #[CoversClass(ProcessExecutionEndedEvent::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 04bacbe..b648268 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; @@ -25,6 +26,9 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; 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; @@ -56,6 +60,10 @@ #[UsesClass(ProcessExtensionRuntime::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] @@ -102,6 +110,33 @@ 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); + } + + /** + * 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 f723f44..ef1c610 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; @@ -27,6 +28,8 @@ 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\LaunchType; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Monolog\Handler\DoctrineProcessHandler; @@ -64,6 +67,9 @@ #[UsesClass(IsValidProcessCodeValidator::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 ee526b9..5aa58f2 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; @@ -22,6 +23,9 @@ use CleverAge\UiProcessBundle\Entity\LogRecord; 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; @@ -35,6 +39,10 @@ #[CoversClass(PurgeLogsCommand::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 402c5fd..14451a8 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; @@ -32,6 +33,9 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; 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; @@ -72,6 +76,10 @@ #[UsesClass(ProcessExtension::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::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 d0738e2..97fbf72 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; @@ -24,6 +25,9 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; 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; @@ -48,6 +52,10 @@ #[UsesClass(ProcessExtension::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(LogRecordCrudController::class)] #[UsesClass(ProcessExecutionCrudController::class)] #[UsesClass(ProcessScheduleCrudController::class)] diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 82141d5..e5bc589 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -72,6 +72,38 @@ public function testDefaultUiOptions(): void $run = $options['run']; self::assertNull($run); self::assertSame(['input' => null, 'context' => []], $options['default']); + 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 diff --git a/tests/Manager/ProcessExecutionManagerEntityManagerTest.php b/tests/Manager/ProcessExecutionManagerEntityManagerTest.php index 8c4bbb2..671b05f 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; @@ -21,6 +22,10 @@ use CleverAge\UiProcessBundle\Entity\LogRecord; 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; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; @@ -44,6 +49,11 @@ #[CoversClass(DoctrineProcessHandler::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::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 e7b8b55..3d53526 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; @@ -21,6 +22,10 @@ use CleverAge\UiProcessBundle\Entity\LogRecord; 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; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; @@ -39,6 +44,11 @@ #[CoversClass(DoctrineProcessHandler::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::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 76152fb..a06ef1e 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; @@ -20,6 +21,10 @@ use CleverAge\UiProcessBundle\Entity\LogRecord; 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; use CleverAge\UiProcessBundle\Tests\App\TestKernel; @@ -34,6 +39,11 @@ #[CoversClass(ProcessExecutionRepository::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::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 0aa1c40..fd74dd8 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; @@ -20,6 +21,10 @@ use CleverAge\UiProcessBundle\Entity\Enum\ProcessScheduleType; 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; use CleverAge\UiProcessBundle\Tests\App\TestKernel; @@ -32,6 +37,11 @@ #[CoversClass(ProcessScheduleRepository::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::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 5ef6448..0bd0030 100644 --- a/tests/StopChecker/ProcessExecutionStopCheckerTest.php +++ b/tests/StopChecker/ProcessExecutionStopCheckerTest.php @@ -16,12 +16,17 @@ 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; use CleverAge\UiProcessBundle\DependencyInjection\Configuration; 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; use CleverAge\UiProcessBundle\Routing\DeprecatedEasyAdminRouteAliasLoader; @@ -37,6 +42,11 @@ #[CoversClass(ProcessExecutionStopChecker::class)] #[UsesClass(CleverAgeUiProcessBundle::class)] #[UsesClass(CheckDoctrineLazyGhostObjectsPass::class)] +#[UsesClass(ProcessConfigurationsManager::class)] +#[UsesClass(LaunchFormOptionsResolver::class)] +#[UsesClass(ProcessContextType::class)] +#[UsesClass(LaunchType::class)] +#[UsesClass(LaunchFormsCacheWarmer::class)] #[UsesClass(CleverAgeUiProcessExtension::class)] #[UsesClass(DeprecatedEasyAdminRouteAliasLoader::class)] #[UsesClass(Configuration::class)] diff --git a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php index 54bd187..08a6a89 100644 --- a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php +++ b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php @@ -47,6 +47,7 @@ public function testGetUiOptions(): void 'ui_launch_mode' => 'modal', 'constraints' => [], 'run' => null, + 'form' => [], ], $options); self::assertSame([], $runtime->getUiOptions('demo.unknown')); }