diff --git a/CHANGELOG.md b/CHANGELOG.md index 749e4d8..a7d7b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Latest ------ ## Changes +* [#160](https://github.com/cleverage/ui-process-bundle/issues/160) Launch form: new `form` UI option declaring the `input` field and the context fields (`form.input`, `form.context.`: `type`, a form type alias such as `text`, `choice`, `checkbox`, `date`, `entity`... or a form type FQCN; `options`, the form type options, with `constraints`; `default`, the initial value), whether free key/value context pairs are allowed (`form.allow_extra_context`, default `true`) and the constraints of the whole form (`form.constraints`). The typed values of the form are converted into process values after the validation by the new `ContextValueNormalizer` (enums, dates, date intervals, `Stringable` objects; files are refused in the context). New `ProcessConfigurationsManager::getLaunchFormDefaults()`; `getUiOptions()` returns the effective `form` option. New `reserved_keys` option of `ProcessContextType`: a free context pair whose key is a declared field is reported as a validation error. The free context pairs of the launch form are now named `launch[context][extra][][key|value]` (was `launch[context][][key|value]`). The `context` child of `LaunchType` is now a compound `FormType` (form theme blocks `_launch_context_extra_entry_*`), and the duplicated keys of `default.context` are merged (the last one wins). The key of a free context pair (launch form and schedules) must be a valid form field name, not only digits (new `key_constraints` option of `ProcessContextType`). The `execution_user` context value is always the email of the connected user (reserved in the free pairs of the launch form and of the schedules, and in `form.context`; constant `ProcessExecuteMessage::EXECUTION_USER_KEY`, constraint `ProcessContextType::createExecutionUserConstraint()`). Existing schedules with a key that is no longer valid keep running, but must be renamed to be saved from the UI. `ProcessConfigurationsManager` resolves only the requested option (an invalid launch form no longer breaks the notifications nor the purge of the logs; conversely, invalid `notification` or `logs` options are no longer reported by the process list, only when used: at the end of an execution in the worker, and by the purge command), and the launch action resolves the UI options once (new `ui_options` option of `LaunchType`, `ProcessConfigurationsManager::buildLaunchFormDefaults()`). Require `symfony/doctrine-bridge` (`entity` type alias). Deprecate the `entrypoint_type` (use `form.input.type`), `default` (use `form.input.default` and `form.context..default`) and `constraints` (use `form.constraints`) UI options, folded into `form` until 4.0, a deprecation being triggered each time the UI options of such a process are resolved. Add documentation, an upgrade guide and tests. * [#158](https://github.com/cleverage/ui-process-bundle/issues/158) CI: test the lowest versions allowed by `composer.json` (`--prefer-lowest`, PHP 8.2). Require `symfony/scheduler` ^6.4.44 || ^7.4.17 || ^8.1.5: the `every` expressions validation (`EveryExpressionValidator`) relies on the `PeriodicalTrigger` rejecting the intervals that do not move the run date forward or resolve to zero seconds (older versions loop forever on e.g. "yesterday", and fail with a `TypeError` on an invalid expression). `ProcessExecutionDurationFilter`: the number of seconds is cast to an integer and inlined in the DQL instead of being bound as a parameter (#111): with DBAL 3, the SQLite platform puts the `SECOND` intervals inside a string literal, so the parameter was never bound ("column index out of range" error on SQLite with DBAL 3). * [#156](https://github.com/cleverage/ui-process-bundle/issues/156) `LogRecord::contextIsEmpty()` (deprecated since v3.1, use `hasContextInfo()`) triggers a deprecation at runtime with `trigger_deprecation()` (`symfony/deprecation-contracts`, now required). Add tests. * [#154](https://github.com/cleverage/ui-process-bundle/issues/154) Add a `.gitattributes`: the development files (`.docker/`, `.github/`, `tests/`, quality tools and PHPUnit configurations, `Makefile`) are no longer part of the archive installed by Composer (`dist`), remove the unused `package.json` (Webpack Encore leftover of the Symfony skeleton: no `webpack.config.js`, no `assets/`). diff --git a/UPGRADE.md b/UPGRADE.md index cff141b..6f6f13a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,98 @@ Upgrade Guide ============= +## v3.3 + +### Deprecated UI options of the launch form + +The `entrypoint_type`, `default` and `constraints` UI options are deprecated in favor of the new `form` option (see +[process UI options](docs/reference/02-process_ui_options.md#form-fields)), and will be removed in 4.0: + +| Deprecated option | Replacement | +|---------------------|-----------------------------------------------------------------------------------| +| `entrypoint_type` | `form.input.type` | +| `default.input` | `form.input.default` | +| `default.context` | `form.context..default` (or a free pair with `form.allow_extra_context`) | +| `constraints` | `form.constraints` | + +```yaml +# Before (3.2) +options: + ui: + ui_launch_mode: form + entrypoint_type: file + default: + context: + - key: delimiter + value: ',' + constraints: + - Collection: + fields: + input: + - File: { extensions: [csv] } + context: + - Collection: + fields: + delimiter: + - Choice: { choices: [',', ';'] } + +# After (3.3) +options: + ui: + ui_launch_mode: form + form: + input: + type: file + options: + constraints: + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + delimiter: + type: choice + default: ',' + options: + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } +``` + +- Mixing a deprecated option and its replacement (e.g. `entrypoint_type` and `form.input`, or `constraints` and + `form.constraints`) raises an `InvalidOptionsException`. +- The HTML names of the free context pairs of the launch form change from `launch[context][][key|value]` to + `launch[context][extra][][key|value]`: update the functional tests of your application that submit this + form. +- The `context` child of `LaunchType` is now a compound `FormType` (the declared fields, then the `extra` + `CollectionType` of the free pairs) instead of a `CollectionType`: update the form theme blocks of your application + (`_launch_context_entry_*` becomes `_launch_context_extra_entry_*`), the form type extensions and the code using + `$form->get('context')`. +- The duplicated keys of `default.context` are merged, the last pair wins (two pairs were displayed). +- A process using a deprecated option triggers its deprecations each time its UI options are resolved (process list, + launch form, executions list): expect them in the deprecation logs until the process is migrated. + +### Context keys of the launch form and the schedules + +- The key of a free context pair (launch form and schedules) must contain letters, digits, `_`, `-` and `:`, start with + a letter, a digit or `_`, and not be only digits, like a declared context key. +- The `execution_user` context value is always the email of the connected user when a process is launched from the + UI: a free pair `execution_user` is refused in the launch form and in the schedules, and `execution_user` cannot be + declared in `form.context`. +- Existing schedules whose context contains a key that is no longer valid (or `execution_user`) keep running unchanged: + the cron does not validate their context. But they cannot be saved from the UI until the key is renamed (or the + pair removed), even to change another field: rename it, and update the process accordingly (e.g. its `{{ key }}` + contextualized options), as renaming the key changes the context received by the process. Enabling or disabling a + schedule from the list is not affected (no form). +- The same applies to the deprecated `default.context` option: a pair whose key is not valid pre-fills a row that + prevents the launch form from being submitted, until the user changes it; the process configuration is not + reported as invalid. Rename the key (or move it to `form.context`). + +### Invalid notification and logs options + +`ProcessConfigurationsManager` now resolves only the requested option: invalid `notification` or `logs` options of a +process are no longer reported by the process list. They are only reported when they are used: at the end of an +execution, by the notifier in the Messenger worker (no notification is sent), and by the +`cleverage:ui-process:purge-logs` command. + ## v3.0 ### Import routes diff --git a/composer.json b/composer.json index 2abd4aa..d4e57cb 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "symfony/console": "^6.4 || ^7.4 || ^8", "symfony/dependency-injection": "^6.4 || ^7.4 || ^8", "symfony/deprecation-contracts": "^2.5 || ^3", + "symfony/doctrine-bridge": "^6.4 || ^7.4 || ^8", "symfony/doctrine-messenger": "^6.4 || ^7.4 || ^8", "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8", "symfony/event-dispatcher-contracts": "^3", diff --git a/docs/cookbooks/form_file_upload.md b/docs/cookbooks/form_file_upload.md index 2d7da4f..e4c2004 100644 --- a/docs/cookbooks/form_file_upload.md +++ b/docs/cookbooks/form_file_upload.md @@ -17,25 +17,24 @@ clever_age_process: source: CSV upload target: Logs ui_launch_mode: form - entrypoint_type: file - default: + form: + input: + type: file + options: + label: CSV file + constraints: + - NotNull: ~ + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] context: - - key: delimiter - value: ',' - constraints: - - Collection: - fields: - input: - - NotNull: ~ - - File: - extensions: [csv] - context: - - Collection: - fields: - delimiter: - - Choice: - choices: [',', ';'] - message: 'delimiter context must be , or ;. {{ value }} given.' + delimiter: + type: choice + default: ',' + options: + label: Delimiter + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } tasks: log_file: service: '@CleverAge\ProcessBundle\Task\Reporting\LoggerTask' @@ -62,17 +61,16 @@ clever_age_process: How it works: - `ui_launch_mode: form` makes the "Launch" action of the process list open a form instead of a confirmation modal (see [launch modes](../reference/02-process_ui_options.md#launch-modes)). -- `entrypoint_type: file` turns the `input` field into a file upload. As the process has an `entry_point`, the field - is required. The uploaded file is saved in the `upload_directory` directory and its path is sent to the - `log_file` task (see [launch form](../reference/02-process_ui_options.md#launch-form)). -- `default.context` pre-fills a `delimiter` line in the context collection - (see [default values](../reference/02-process_ui_options.md#default-values)). It is injected into the - `InputCsvReaderTask` through the `{{ delimiter }}` contextualized option, exactly like `-c delimiter:";"` on the - console. -- `constraints` validate the whole form data (`input` and `context`): the process is not queued while the file is not - a CSV or the delimiter is not `,` or `;` (see [constraints](../reference/02-process_ui_options.md#constraints)). - As the `Collection` constraint rejects extra fields, users cannot add other context values: add - `allowExtraFields: true` to allow them. +- `form.input` with `type: file` turns the `input` field into a file upload. As the process has an `entry_point`, the + field is required (HTML attribute); its `NotNull` and `File` constraints are checked by the server: the process is + not queued while no file is uploaded or the file is not a CSV, and the error is displayed under the field. The + uploaded file is saved in the `upload_directory` directory and its path is sent to the `log_file` task (see + [launch form](../reference/02-process_ui_options.md#launch-form)). +- `form.context.delimiter` adds a `delimiter` select, pre-selected on `,` + (see [form fields](../reference/02-process_ui_options.md#form-fields)). A value outside of the choices is refused by + the field itself. The selected value is injected into the `InputCsvReaderTask` through the `{{ delimiter }}` + contextualized option, exactly like `-c delimiter:";"` on the console. +- Users can still add free key/value pairs to the context: set `form.allow_extra_context: false` to forbid them. - On submit, a message is sent to the `execute_process` transport, so a `messenger:consume execute_process` worker must be running (see [messenger](../reference/08-messenger.md)). The connected user email is added to the context as `execution_user`. @@ -92,4 +90,4 @@ curl --location 'https://your-domain.com/http/process/execute' \ --form 'context[delimiter]=";"' ``` -Note that the launch form `constraints` are not applied to HTTP API calls. +Note that the launch form fields and constraints are not applied to HTTP API calls. diff --git a/docs/reference/02-process_ui_options.md b/docs/reference/02-process_ui_options.md index 5421500..626b59d 100644 --- a/docs/reference/02-process_ui_options.md +++ b/docs/reference/02-process_ui_options.md @@ -19,6 +19,16 @@ clever_age_process: source: target: ui_launch_mode: + form: + input: # + type: # default: text + options: # form type options, default: [] + default: # initial value, default: null + context: + : # same keys as "input" + allow_extra_context: # default: true + constraints: + # Deprecated since 3.3, use "form" instead entrypoint_type: default: input: @@ -45,9 +55,10 @@ Options | `source` | `string`, `null` | `null` | Free label describing where the data comes from. Displayed in the process list and in the executions list. | | `target` | `string`, `null` | `null` | Free label describing where the data goes. Displayed in the process list and in the executions list. | | `ui_launch_mode` | `string`, `null` | `modal` | Behaviour of the "Launch" (rocket) action of the process list. See [launch modes](#launch-modes). | -| `entrypoint_type` | `string` | `text` | `text` or `file`: type of the `input` field of the launch form (`form` launch mode only). | -| `default` | `array` | `{input: null, context: []}` | Initial values of the launch form (`form` launch mode only). See [default values](#default-values). | -| `constraints` | `array` | `[]` | Symfony validation constraints applied to the launch form (`form` launch mode only). See [constraints](#constraints). | +| `form` | `array` | `[]` | Fields, default values and constraints of the launch form (`form` launch mode only). See [form fields](#form-fields). | +| `entrypoint_type` | `string` | `text` | *Deprecated since 3.3, use `form.input.type`.* `text` or `file`: type of the `input` field of the launch form. | +| `default` | `array` | `{input: null, context: []}` | *Deprecated since 3.3, use `form.input.default` and `form.context..default`.* See [default values](#default-values). | +| `constraints` | `array` | `[]` | *Deprecated since 3.3, use `form.constraints`.* Constraints applied to the launch form. See [constraints](#constraints). | | `run` | `mixed` | `null` | Accepted for backward compatibility, not used. | Process visibility @@ -69,28 +80,184 @@ Launch modes In every mode, the process is not executed by the web request: a message is dispatched to the `execute_process` Messenger transport and a worker runs it (see [messenger](08-messenger.md)). The UI adds the email of the connected -user to the process context, under the `execution_user` key. +user to the process context, under the `execution_user` key: this value always wins, the launch form cannot set it. ### Launch form The launch form (`/process/launch?process=`) contains: -- an `input` field: a text field when `entrypoint_type` is `text`, a file upload field when it is `file`. It is - required if the process defines an `entry_point`. -- a `context` collection of key/value pairs (both are required when a line is added). Each pair becomes a process - context value, like `-c key:value` on the `cleverage:process:execute` console command. +- an `input` field, of the type declared by `form.input` (a text field by default). It is required (HTML attribute) + if the process defines an `entry_point`, unless `form.input.options.required` says otherwise. +- a `context` group: one field per key declared in `form.context`, followed (when `form.allow_extra_context` is + `true`, the default) by a collection of free key/value pairs (both are required when a line is added). Each field + or pair becomes a process context value, like `-c key:value` on the `cleverage:process:execute` console command. -When `entrypoint_type` is `file`, the uploaded file is saved to the `upload_directory` directory (see +The input can be of any type, but only a single uploaded file is saved: when the input is a `file` field, the +uploaded file is saved to the `upload_directory` directory (see [bundle configuration](01-bundle_configuration.md#container-parameters)) with a random name keeping the original extension, and its absolute path is passed as process input. The file is not removed after the execution. +Form fields +----------- + +`form` declares the fields of the launch form: + +```yaml +options: + ui: + ui_launch_mode: form + form: + input: + type: file + options: + label: CSV file + constraints: + - NotNull: ~ + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + delimiter: + type: choice + default: ',' + options: + label: Delimiter + choices: { 'Comma (,)': ',', 'Semicolon (;)': ';' } + dry_run: + type: checkbox + default: false + options: { required: false } + comment: ~ # a text field without default value + allow_extra_context: true + constraints: [] +``` + +| Key | Type | Default | Description | +|----------------------------|------------------------|---------|------------------------------------------------------------------------------------------------------------------| +| `form.input` | field spec, `null` | `null` | The `input` field. `null`: a text field (or the deprecated `entrypoint_type`). | +| `form.context` | map of field specs | `[]` | The declared context fields, by context key, in their display order. `null` is a text field without default. | +| `form.allow_extra_context` | `bool` | `true` | Whether free key/value pairs can be added to the context, for the keys that are not declared. | +| `form.constraints` | `array` | `[]` | Constraints applied to the whole form data, see [constraints](#constraints). | + +### Field spec + +A field spec (`form.input`, `form.context.`) accepts exactly three keys: + +| Key | Type | Default | Description | +|-----------|----------|---------|----------------------------------------------------------------------------------------------------------------| +| `type` | `string` | `text` | A form type alias (see below) or the FQCN of a Symfony form type. | +| `options` | `array` | `[]` | The options of the form type (`label`, `help`, `attr`, `choices`, `constraints`...). | +| `default` | `mixed` | `null` | The initial value of the field. | + +The context keys must be valid form field names (letters, digits, `_`, `-` and `:`, starting with a letter, a digit +or `_`). A purely numeric key (e.g. `123`, even quoted in YAML) is not supported: PHP casts it to an integer. The +`extra` key (the free context pairs) and the `execution_user` key (the email of the connected user) are reserved. + +The type aliases are the block prefixes of the Symfony core form types, and `entity`: + +| Alias | Form type | +|-----------------------------------------------------------------------------|---------------------------------------------------------------------------------| +| `text`, `textarea`, `email`, `password`, `search`, `url`, `tel`, `hidden` | `TextType`, `TextareaType`, `EmailType`, `PasswordType`, `SearchType`, `UrlType`, `TelType`, `HiddenType` | +| `integer`, `number`, `money`, `percent`, `range` | `IntegerType`, `NumberType`, `MoneyType`, `PercentType`, `RangeType` | +| `checkbox`, `radio`, `choice`, `enum` | `CheckboxType`, `RadioType`, `ChoiceType`, `EnumType` | +| `country`, `currency`, `language`, `locale`, `timezone` | `CountryType`, `CurrencyType`, `LanguageType`, `LocaleType`, `TimezoneType` | +| `date`, `datetime`, `time`, `birthday`, `week`, `dateinterval` | `DateType`, `DateTimeType`, `TimeType`, `BirthdayType`, `WeekType`, `DateIntervalType` | +| `color`, `file`, `uuid`, `ulid`, `collection`, `repeated` | `ColorType`, `FileType`, `UuidType`, `UlidType`, `CollectionType`, `RepeatedType` | +| `entity` | `Symfony\Bridge\Doctrine\Form\Type\EntityType` | + +The aliases are case-sensitive. Any other type is a FQCN: it must implement `Symfony\Component\Form\FormTypeInterface` +and must not be a button type. Only the existence of the class is checked when the options are resolved: a form type +with dependencies must be a service (tag `form.type`, autoconfigured by default), and an option that the type does +not define (e.g. `choices` on a `text` field) fails when the form is built (500 on the launch page). + +### Constraints of a field + +`options.constraints` uses the same syntax as the [`constraints`](#constraints) option: a list of constraint short +names (or FQCN) with their options. A violation is displayed under the field. + +The `extensions` option of the `File` constraint also checks the detected MIME type of the file. A CSV file is +often detected as `text/plain` (semicolon delimiter, single column, few lines): list the accepted MIME types per +extension (`extensions: { csv: ['text/csv', 'text/plain'] }`). + +`options.required` is only the HTML `required` attribute (Symfony default: `true`, including for a `checkbox`, which +must then be checked): the server-side validation relies on `options.constraints` (e.g. `NotBlank`). + +The `data`, `mapped`, `property_path` and `inherit_data` options are not supported (they break the mapping of the +context and of the default values): use `default` for the initial value. + +### Default values of the fields + +`default` is the initial value of the field, in the model format of its type: +- a `file` field cannot have a default value; +- for `date`, `datetime` and `time`, the YAML value is a string: set `options.input: string` (with the matching + `options.input_format`), or keep a `null` default, otherwise the string cannot be transformed; +- for `choice`, the default value is one of the choices values; +- for `dateinterval`, the YAML value is a string: set `options.input: string` (an ISO 8601 duration such as `P1D`), or + keep a `null` default. + +### File fields + +A `file` field is only supported for the input, and for a single file (`options.multiple: true` is refused). A form +type whose parent is `FileType` (through `getParent()`) is not detected as a file field. + +### Free context pairs + +With `allow_extra_context: true`, the free key/value pairs are displayed after the declared fields, under a "Context +(key/value)" label (no label when no context key is declared). With `allow_extra_context: false`, no free pair can be +added. + +The key of a free pair follows the rule of the declared context keys: letters, digits, `_`, `-` and `:`, starting with +a letter, a digit or `_`, and not only digits. Otherwise, it is reported as a validation error on its key (`The +context key "a b" is not valid: ...`), and the process is not launched. The same applies to a free pair whose key is: +- a declared field (`The context key "delimiter" is a field of the launch form, use this field instead.`); +- `execution_user` (`The context key "execution_user" is reserved: it is set to the email of the connected user.`). + +The key format rule also applies to the context pairs of the [schedules](05-scheduler.md). + +> **Free pairs and placeholders.** A free pair gives a value to any context key, so to any `{{ key }}` placeholder of +> the options of the process tasks, not only to the keys declared in `form.context`. As soon as a process declares its +> context fields in `form.context`, set `allow_extra_context: false` so that only these keys can be set from the UI +> (the default stays `true`, for backward compatibility). + +The HTML names of the fields are `launch[input]`, `launch[context][]` for the declared fields and +`launch[context][extra][][key|value]` for the free pairs. + +### Context values + +The values of the form are converted before being given to the process (after the validation, so that the +constraints apply to the typed values): + +| Form data | Process value | +|-----------------------------------------------|-------------------------------------------------------------------| +| `null`, `bool`, `int`, `float`, `string` | unchanged | +| `array` | converted recursively, keys kept | +| backed enum | its `value` | +| pure enum | its `name` | +| `DateTimeInterface` | formatted as `DATE_ATOM` (`2026-01-02T03:04:05+01:00`) | +| `DateInterval` | an ISO 8601 duration with every part (`P0Y0M1DT0H0M0S`), `-` prefixed when inverted | +| file | refused (`LogicException`): file fields are only supported for the input | +| `Stringable` object | its string | +| other object | refused (`LogicException`, 500) | + +A declared field left empty gives the `null` value (`false` for a `checkbox`). + +> **`entity` fields.** The data of an `EntityType` is the entity itself: the `choice_value` option only changes the +> HTML value of the options, not the data given to the process. The entity must implement `\Stringable`: its string +> becomes the context value. Otherwise the launch fails with a `LogicException` ("cannot be used as a process context +> value"). Prefer a `choice` field with scalar choices (e.g. the identifiers of the entities). + Default values -------------- +*Deprecated since 3.3, use `form.input.default` and `form.context..default` (see +[migrating from the deprecated options](#migrating-from-the-deprecated-options)).* + `default.input` pre-fills the input field. It is ignored when `entrypoint_type` is `file` (a file field cannot be pre-filled). `default.context` is a list of `key`/`value` pairs (both required) pre-filling the context collection. The user can -change, remove or add pairs before launching. +change, remove or add pairs before launching. A pair whose key is declared in `form.context` pre-fills this field (if +the field has no `default`); when a key appears several times, the last pair wins. ```yaml options: @@ -109,11 +276,15 @@ options: Constraints ----------- -`constraints` uses the same syntax as the [ValidatorTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/validator_task.md) +*The `constraints` option is deprecated since 3.3, use `form.constraints`, which has the same syntax.* + +`form.constraints` (or the deprecated `constraints`) uses the same syntax as the [ValidatorTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/validator_task.md) of the process bundle (and as Symfony YAML validation mapping): a list of constraint short names (or FQCN) with their options. Constraints are applied to the whole form data, which is an array with two keys: -- `input`: the input string, or the `UploadedFile` object when `entrypoint_type` is `file`, -- `context`: the context as an associative array (`key => value`). +- `input`: the data of the input field (e.g. a string, or the `UploadedFile` object of a `file` field), +- `context`: the context as an associative array (`key => value`), the declared keys first, with their typed values. + +A violation on a declared context key (`[context][]`) is displayed under its field. While a constraint is violated, the process is not launched and the form is displayed again with the violation messages. @@ -122,27 +293,57 @@ messages. options: ui: ui_launch_mode: form - entrypoint_type: file - default: + form: + input: + type: file context: - - key: delimiter - value: ',' - constraints: - - Collection: - fields: - input: - - File: - extensions: [csv] - context: - - Collection: - fields: - delimiter: - - Choice: - choices: [',', ';'] - message: 'delimiter context must be , or ;. {{ value }} given.' + delimiter: + default: ',' + constraints: + - Collection: + fields: + input: + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + - Collection: + fields: + delimiter: + - Choice: + choices: [',', ';'] + message: 'delimiter context must be , or ;. {{ value }} given.' ``` +The constraints of a single field are simpler to declare in its `options.constraints` (see +[form fields](#constraints-of-a-field)). + Note that the `Collection` constraint rejects missing and extra fields by default: use `allowExtraFields: true` (or `allowMissingFields: true`) if users may add other context values. See the [form with file upload](../cookbooks/form_file_upload.md) cookbook for a complete example. + +Migrating from the deprecated options +------------------------------------- + +Since 3.3, `entrypoint_type`, `default` and `constraints` are deprecated (a deprecation is triggered each time the +UI options of the process are resolved: process list, launch form, executions list; when the option is set, even to +an empty value), and will be removed in 4.0: + +| Deprecated option | Replacement | +|---------------------|-----------------------------------------------------------------------------------------------| +| `entrypoint_type` | `form.input.type` | +| `default.input` | `form.input.default` | +| `default.context` | `form.context..default` (or a free pair with `form.allow_extra_context`) | +| `constraints` | `form.constraints` | + +Until then, the deprecated options are folded into `form`. Mixing a deprecated option and its replacement raises an +`InvalidOptionsException`: +- `entrypoint_type` with `form.input`; +- `constraints` with `form.constraints` (even empty); +- `default.input` with `form.input.default`; +- a `default.context` pair with the `default` of the same declared key; +- a `default.context` pair whose key is not declared, with `form.allow_extra_context: false`. + +See the [upgrade guide](../../UPGRADE.md) for an example. diff --git a/docs/reference/04-process_executions_and_logs.md b/docs/reference/04-process_executions_and_logs.md index 6ff4eec..d1e03e7 100644 --- a/docs/reference/04-process_executions_and_logs.md +++ b/docs/reference/04-process_executions_and_logs.md @@ -31,7 +31,7 @@ Executions are stored in the `process_execution` table (`CleverAge\UiProcessBund | `heartbeatAt` | Last sign of life of the running process, see [not responding executions](#not-responding-executions). | | `logFilename` | Name of the log file. | | `report` | Associative array, see [report](#report). | -| `context` | Process context (including `execution_user` when launched from the UI). | +| `context` | Process context (including `execution_user` when launched from the UI, reserved in the launch form and the schedules). | ### Executions list @@ -193,6 +193,9 @@ clever_age_process: # ... ``` +Invalid `logs` options raise an options resolver error when the purge command runs (since 3.3, they are no longer +reported by the process list): run the command with `--dry-run` after changing them. + Schedule the command, e.g. every night with a cron: ``` diff --git a/docs/reference/05-scheduler.md b/docs/reference/05-scheduler.md index a2481e7..89d26a7 100644 --- a/docs/reference/05-scheduler.md +++ b/docs/reference/05-scheduler.md @@ -15,7 +15,7 @@ Schedules are stored in the `process_schedule` table (`CleverAge\UiProcessBundle | `type` | `cron` or `every`. | | `expression` | `cron`: a cron expression, e.g. `*/5 * * * *` or `@daily` (see [cron expression triggers](https://symfony.com/doc/current/scheduler.html#cron-expression-triggers)). `every`: a relative time, e.g. `5 seconds`, `1 hour`, `1 day` (see [periodical triggers](https://symfony.com/doc/current/scheduler.html#periodical-triggers)); it must be parsable by `strtotime()`. | | `input` | Optional process input (string, 255 characters max), e.g. a file path. | -| `context` | Optional list of key/value pairs, passed as process context. | +| `context` | Optional list of key/value pairs, passed as process context. A key contains letters, digits, `_`, `-` and `:`, starts with a letter, a digit or `_`, and is not only digits. The `execution_user` key is reserved (set to the email of the connected user when a process is launched from the UI). | | `enabled` | A disabled schedule is not run (default: enabled). It can be switched directly from the schedules list. | The schedules list displays the next execution date of the enabled `cron` schedules. diff --git a/docs/reference/09-notifications.md b/docs/reference/09-notifications.md index 02f42f2..2d4bf67 100644 --- a/docs/reference/09-notifications.md +++ b/docs/reference/09-notifications.md @@ -77,7 +77,9 @@ clever_age_process: | `recipients` | `array[]` | Recipients, with an `email` and/or a `phone` (required by the `email` and `sms` channels). Empty: the `admin_recipients` of the notifier. | A key missing (or `null`) in the process options is inherited from the bundle configuration. Invalid process options -raise an options resolver error. +raise an options resolver error when the notification is sent, at the end of an execution, in the Messenger worker: +no notification is then sent. Since 3.3, they are no longer reported by the process list, check them when you change +them. Statuses -------- diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php index 3d9f5e2..797560a 100644 --- a/src/Controller/Admin/Process/LaunchAction.php +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -15,6 +15,7 @@ use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\UiProcessBundle\Entity\User; +use CleverAge\UiProcessBundle\Form\ContextValueNormalizer; use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; @@ -23,7 +24,6 @@ use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -77,31 +77,35 @@ public function __invoke( LaunchType::class, null, [ - 'constraints' => $uiOptions['constraints'], + 'constraints' => $uiOptions['form']['constraints'], 'process_code' => $processCode, + // Not resolved again by the form type + 'ui_options' => $uiOptions, ] ); if (false === $form->isSubmitted()) { - $default = $uiOptions['default']; - if (false === $form->get('input')->getConfig()->getType()->getInnerType() instanceof TextType - && isset($default['input']) - ) { - unset($default['input']); - } - $form->setData($default); + $form->setData($this->processConfigurationsManager->buildLaunchFormDefaults($uiOptions)); } $form->handleRequest($this->requestStack->getMainRequest()); if ($form->isSubmitted() && $form->isValid()) { + // Normalized after the validation: the constraints apply to the typed values + $normalizer = new ContextValueNormalizer(); $input = $form->get('input')->getData(); if ($input instanceof UploadedFile) { $filename = \sprintf('%s/%s.%s', $uploadDirectory, Uuid::v4(), $input->getClientOriginalExtension()); (new Filesystem())->dumpFile($filename, $input->getContent()); $input = $filename; + } else { + $input = $normalizer->normalize($input, 'input'); } /** @var string $processCode */ $processCode = $form->getConfig()->getOption('process_code'); - /** @var array $context */ - $context = $form->get('context')->getData(); + /** @var array $data */ + $data = $form->get('context')->getData() ?? []; + $context = []; + foreach ($data as $key => $value) { + $context[(string) $key] = $normalizer->normalize($value, (string) $key); + } $this->dispatch($processCode, $input, $context); $this->addFlash( 'success', @@ -128,10 +132,8 @@ protected function dispatch(string $processCode, mixed $input = null, array $con $message = new ProcessExecuteMessage( $processCode, $input, - array_merge( - ['execution_user' => $this->getUser()?->getEmail()], - $context - ) + // The connected user always wins over a context value + [ProcessExecuteMessage::EXECUTION_USER_KEY => $this->getUser()?->getEmail()] + $context ); $this->messageBus->dispatch($message); } diff --git a/src/Controller/Admin/ProcessScheduleCrudController.php b/src/Controller/Admin/ProcessScheduleCrudController.php index 46212de..2e53a62 100644 --- a/src/Controller/Admin/ProcessScheduleCrudController.php +++ b/src/Controller/Admin/ProcessScheduleCrudController.php @@ -108,6 +108,8 @@ public function configureFields(string $pageName): iterable ->setFormTypeOption('entry_type', ProcessContextType::class) ->hideOnIndex() ->setFormTypeOption('entry_options.label', 'Context (key/value)') + // As in the launch form: set to the email of the connected user when a process is launched from the UI + ->setFormTypeOption('entry_options.key_constraints', [ProcessContextType::createExecutionUserConstraint()]) ->setFormTypeOption('label', '') ->setFormTypeOption('required', false), ]; diff --git a/src/Form/ContextValueNormalizer.php b/src/Form/ContextValueNormalizer.php new file mode 100644 index 0000000..ed2bf7a --- /dev/null +++ b/src/Form/ContextValueNormalizer.php @@ -0,0 +1,65 @@ +|null + * + * @throws \LogicException when the value cannot be converted + */ + public function normalize(mixed $value, string $name): mixed + { + if (null === $value || \is_scalar($value)) { + return $value; + } + if (\is_array($value)) { + $normalized = []; + foreach ($value as $key => $item) { + $normalized[$key] = $this->normalize($item, \sprintf('%s[%s]', $name, $key)); + } + + return $normalized; + } + // A backed enum is also a unit enum + if ($value instanceof \BackedEnum) { + return $value->value; + } + if ($value instanceof \UnitEnum) { + return $value->name; + } + if ($value instanceof \DateTimeInterface) { + return $value->format(\DATE_ATOM); + } + // An ISO 8601 duration, "-" prefixed when inverted + if ($value instanceof \DateInterval) { + return $value->format('%rP%yY%mM%dDT%hH%iM%sS'); + } + // A file is also stringable + if ($value instanceof \SplFileInfo) { + throw new \LogicException(\sprintf('The value of "%s" is a file ("%s"): file fields are only supported for the input.', $name, $value::class)); + } + if ($value instanceof \Stringable) { + return (string) $value; + } + + throw new \LogicException(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', $name, get_debug_type($value))); + } +} diff --git a/src/Form/Type/LaunchType.php b/src/Form/Type/LaunchType.php index 35551e1..1604bcc 100644 --- a/src/Form/Type/LaunchType.php +++ b/src/Form/Type/LaunchType.php @@ -14,18 +14,24 @@ namespace CleverAge\UiProcessBundle\Form\Type; use CleverAge\ProcessBundle\Configuration\TaskConfiguration; +use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; -/** @template-extends AbstractType> */ +/** + * @phpstan-import-type UiOptions from ProcessConfigurationsManager + * + * @template-extends AbstractType> + */ class LaunchType extends AbstractType { public function __construct( @@ -39,41 +45,86 @@ public function buildForm(FormBuilderInterface $builder, array $options): void /** @var string $code */ $code = $options['process_code']; $configuration = $this->registry->getProcessConfiguration($code); - $uiOptions = $this->configurationsManager->getUiOptions($code); - if (isset($uiOptions['entrypoint_type'])) { - $builder->add( - 'input', - 'file' === $uiOptions['entrypoint_type'] ? FileType::class : TextType::class, + /** @var UiOptions $uiOptions */ + $uiOptions = $options['ui_options'] ?? $this->configurationsManager->getUiOptions($code) ?? throw MissingProcessException::create($code); + $form = $uiOptions['form']; + + // A "required" form option of the input takes precedence + $builder->add( + 'input', + $form['input']['type'], + $form['input']['options'] + ['required' => $configuration->getEntryPoint() instanceof TaskConfiguration] + ); + + // Not "required: false": a child of a form that is not required is never required (HTML attribute), the + // declared fields follow their own "required" option; the free context pairs are not required + $context = $builder->create('context', FormType::class); + foreach ($form['context'] as $key => $field) { + $context->add($key, $field['type'], $field['options']); + } + if ($form['allow_extra_context']) { + $context->add( + 'extra', + CollectionType::class, [ - 'required' => $configuration->getEntryPoint() instanceof TaskConfiguration, + 'entry_type' => ProcessContextType::class, + 'entry_options' => [ + 'reserved_keys' => array_keys($form['context']), + 'key_constraints' => [ProcessContextType::createExecutionUserConstraint()], + ], + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + 'mapped' => false, + 'label' => [] === $form['context'] ? false : 'Context (key/value)', ] ); + // On POST_SET_DATA: the data mapper resets the unmapped children with their configured data after PRE_SET_DATA + $context->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($form): void { + /** @var array $data */ + $data = $event->getData() ?? []; + $rows = []; + foreach (array_diff_key($data, $form['context']) as $key => $value) { + $rows[] = ['key' => $key, 'value' => $value]; + } + $event->getForm()->get('extra')->setData($rows); + }); } - $builder->add( - 'context', - CollectionType::class, - [ - 'entry_type' => ProcessContextType::class, - 'allow_add' => true, - 'allow_delete' => true, - 'required' => false, - ] - ); - $builder->get('context')->addModelTransformer(new CallbackTransformer( - static fn ($data) => $data ?? [], - // A row without key is left out (it is reported by the NotBlank constraint of ProcessContextType): a null - // key is deprecated as array offset, and an empty key breaks the property paths of the process constraints - static fn (?array $data) => array_column( - array_filter($data ?? [], static fn ($row): bool => \is_array($row) && null !== ($row['key'] ?? null) && '' !== $row['key']), - 'value', - 'key' - ), - )); + $context->addEventListener(FormEvents::SUBMIT, static function (FormEvent $event) use ($form): void { + /** @var array $data */ + $data = $event->getData() ?? []; + $extra = []; + if ($event->getForm()->has('extra')) { + /** @var array $rows */ + $rows = $event->getForm()->get('extra')->getData() ?? []; + // A row without key is left out (it is reported by the NotBlank constraint of ProcessContextType): a + // null key is deprecated as array offset, and an empty key breaks the property paths of the process + // constraints + $extra = array_column( + array_filter($rows, static fn ($row): bool => \is_array($row) && null !== ($row['key'] ?? null) && '' !== $row['key']), + 'value', + 'key' + ); + } + // The declared keys take precedence, a free row with a declared key is reported by ProcessContextType + $event->setData(array_intersect_key($data, $form['context']) + $extra); + }); + $builder->add($context); + } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + // The context group itself is not required: its label is rendered as before 3.3, without the required mark + $view['context']->vars['required'] = false; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setRequired('process_code'); + // The UI options of the process, as resolved by ProcessConfigurationsManager::getUiOptions(): resolved from + // the process code when null + $resolver->setDefault('ui_options', null); + $resolver->setAllowedTypes('ui_options', ['null', 'array']); } #[\Override] diff --git a/src/Form/Type/ProcessContextType.php b/src/Form/Type/ProcessContextType.php index d95fd19..4eac854 100644 --- a/src/Form/Type/ProcessContextType.php +++ b/src/Form/Type/ProcessContextType.php @@ -13,23 +13,49 @@ namespace CleverAge\UiProcessBundle\Form\Type; +use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\Regex; /** @template-extends AbstractType */ class ProcessContextType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + $keyConstraints = [ + new NotBlank(), + // The rule of FormConfigBuilder::isValidName() applied to the declared context keys of the launch form, and + // not only digits (PHP casts such an array key to an integer) + new Regex( + pattern: '/^(?![0-9]+$)[a-zA-Z0-9_][a-zA-Z0-9_\-:]*$/D', + message: 'The context key {{ value }} is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.' + ), + ]; + /** @var string[] $reservedKeys */ + $reservedKeys = $options['reserved_keys']; + if ([] !== $reservedKeys) { + // The keys of the fields of the launch form + $keyConstraints[] = new Choice( + choices: $reservedKeys, + message: 'The context key {{ value }} is a field of the launch form, use this field instead.', + match: false + ); + } + /** @var Constraint[] $additionalKeyConstraints */ + $additionalKeyConstraints = $options['key_constraints']; + $keyConstraints = [...$keyConstraints, ...$additionalKeyConstraints]; $builder->add( 'key', null, [ 'label' => 'Context Key', 'attr' => ['placeholder' => 'key'], - 'constraints' => [new NotBlank()], + 'constraints' => $keyConstraints, ] )->add( 'value', @@ -42,7 +68,25 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ); } + /** + * The constraint of a context key reserving "execution_user", set to the email of the connected user when a process + * is launched from the UI. + */ + public static function createExecutionUserConstraint(): Choice + { + return new Choice( + choices: [ProcessExecuteMessage::EXECUTION_USER_KEY], + message: 'The context key {{ value }} is reserved: it is set to the email of the connected user.', + match: false + ); + } + public function configureOptions(OptionsResolver $resolver): void { + $resolver->setDefault('reserved_keys', []); + $resolver->setAllowedTypes('reserved_keys', 'string[]'); + // Additional constraints of the key + $resolver->setDefault('key_constraints', []); + $resolver->setAllowedTypes('key_constraints', Constraint::class.'[]'); } } diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index d2e36d8..2c6c97a 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -14,15 +14,46 @@ namespace CleverAge\UiProcessBundle\Manager; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; +use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\ProcessBundle\Validator\ConstraintLoader; +use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\ButtonTypeInterface; +use Symfony\Component\Form\Extension\Core\Type; +use Symfony\Component\Form\FormConfigBuilder; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\Form\SubmitButtonTypeInterface; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraint; /** + * @phpstan-type FormFieldOptions array{ + * 'type': class-string>, + * 'options': array, + * 'default': mixed + * } + * @phpstan-type FormOptions array{ + * 'input': FormFieldOptions, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[] + * } + * @phpstan-type ResolvedFormOptions array{ + * 'input': FormFieldOptions|null, + * 'context': array, + * 'allow_extra_context': bool, + * 'constraints': Constraint[] + * } + * @phpstan-type ResolvedUiOptions array{ + * 'entrypoint_type': string, + * 'constraints': Constraint[], + * 'default': array{'input': mixed, 'context': array}, + * 'form': ResolvedFormOptions + * } * @phpstan-type UiOptions array{ * 'source': ?string, * 'target': ?string, @@ -30,8 +61,10 @@ * 'entrypoint_type': string, * 'constraints': Constraint[], * 'run': ?bool, - * 'default': array{'input': mixed, 'context': array} + * 'default': array{'input': mixed, 'context': array}, + * 'form': FormOptions * } + * @phpstan-type LaunchFormData array{'input': mixed, 'context': array} * @phpstan-type NotificationOptions array{ * 'enabled': ?bool, * 'statuses': ?string[], @@ -45,6 +78,54 @@ */ final readonly class ProcessConfigurationsManager { + /** + * The form type aliases of the launch form fields: the block prefixes of the Symfony core data types, and "entity". + * + * @var array + */ + private const FORM_TYPE_ALIASES = [ + 'text' => Type\TextType::class, + 'textarea' => Type\TextareaType::class, + 'email' => Type\EmailType::class, + 'password' => Type\PasswordType::class, + 'search' => Type\SearchType::class, + 'url' => Type\UrlType::class, + 'tel' => Type\TelType::class, + 'hidden' => Type\HiddenType::class, + 'integer' => Type\IntegerType::class, + 'number' => Type\NumberType::class, + 'money' => Type\MoneyType::class, + 'percent' => Type\PercentType::class, + 'range' => Type\RangeType::class, + 'checkbox' => Type\CheckboxType::class, + 'radio' => Type\RadioType::class, + 'choice' => Type\ChoiceType::class, + 'enum' => Type\EnumType::class, + 'country' => Type\CountryType::class, + 'currency' => Type\CurrencyType::class, + 'language' => Type\LanguageType::class, + 'locale' => Type\LocaleType::class, + 'timezone' => Type\TimezoneType::class, + 'date' => Type\DateType::class, + 'datetime' => Type\DateTimeType::class, + 'time' => Type\TimeType::class, + 'birthday' => Type\BirthdayType::class, + 'week' => Type\WeekType::class, + 'dateinterval' => Type\DateIntervalType::class, + 'color' => Type\ColorType::class, + 'file' => Type\FileType::class, + 'uuid' => Type\UuidType::class, + 'ulid' => Type\UlidType::class, + 'collection' => Type\CollectionType::class, + 'repeated' => Type\RepeatedType::class, + 'entity' => EntityType::class, + ]; + + /** + * The form options of a launch form field that break the mapping of the context or the default values. + */ + private const UNSUPPORTED_FIELD_OPTIONS = ['data', 'mapped', 'property_path', 'inherit_data']; + public function __construct(private ProcessConfigurationRegistry $registry) { } @@ -72,7 +153,42 @@ public function getUiOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions())['ui']; + /** @var UiOptions $options */ + $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'ui')['ui']; + + return $options; + } + + /** + * The initial data of the launch form of the process: the default value of the input, and the default values of + * the context, the declared keys first (in their order of declaration), then the free keys of the deprecated + * "default.context" option. + * + * @return LaunchFormData + */ + public function getLaunchFormDefaults(string $processCode): array + { + return $this->buildLaunchFormDefaults($this->getUiOptions($processCode) ?? throw MissingProcessException::create($processCode)); + } + + /** + * The initial data of the launch form, from the UI options already resolved by getUiOptions(). + * + * @param UiOptions $uiOptions + * + * @return LaunchFormData + */ + public function buildLaunchFormDefaults(array $uiOptions): array + { + $context = array_map(static fn (array $field): mixed => $field['default'], $uiOptions['form']['context']); + foreach ($uiOptions['default']['context'] as $row) { + $key = (string) $row['key']; + if (!\array_key_exists($key, $uiOptions['form']['context'])) { + $context[$key] = $row['value']; + } + } + + return ['input' => $uiOptions['form']['input']['default'], 'context' => $context]; } /** @@ -88,7 +204,10 @@ public function getNotificationOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions())['notification']; + /** @var NotificationOptions $options */ + $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'notification')['notification']; + + return $options; } /** @@ -104,115 +223,344 @@ public function getLogsOptions(string $processCode): ?array $configuration = $this->registry->getProcessConfiguration($processCode); - return $this->resolveOptions($configuration->getOptions())['logs']; + /** @var LogsOptions $options */ + $options = $this->resolveOptions($configuration->getOptions(), $processCode, 'logs')['logs']; + + return $options; } /** - * @param array $options + * Resolves the given option only: the other ones are only checked as arrays, so that an invalid option (e.g. the + * launch form) does not break the others (e.g. the notification of the executions or the purge of the logs). + * + * @param array $options + * @param 'ui'|'notification'|'logs' $option * - * @return array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} + * @return array{'ui': array, 'notification': array, 'logs': array} */ - private function resolveOptions(array $options): array + private function resolveOptions(array $options, string $processCode, string $option): array { $resolver = new OptionsResolver(); - $resolver->setDefault('notification', []); - $resolver->setAllowedTypes('notification', 'array'); - $resolver->setNormalizer('notification', static function (Options $options, array $notification): array { - $notificationResolver = new OptionsResolver(); - $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); - $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); - $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); - $notificationResolver->setAllowedValues('statuses', static function (?array $statuses): bool { - /** @var string[]|null $statusValues */ - $statusValues = $statuses; - - return null === $statusValues || [] === array_diff($statusValues, NotificationTrigger::values()); - }); - $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); - $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); - $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { - if (null === $recipients) { - return null; + $resolver->setDefaults(['notification' => [], 'logs' => [], 'ui' => []]); + foreach (['notification', 'logs', 'ui'] as $name) { + $resolver->setAllowedTypes($name, 'array'); + } + $resolver->setNormalizer($option, match ($option) { + 'notification' => static fn (Options $options, array $notification): array => self::resolveNotificationOptions($notification), + 'logs' => static fn (Options $options, array $logs): array => self::resolveLogsOptions($logs), + 'ui' => static fn (Options $options, array $ui): array => self::resolveUiOptions($ui, $processCode), + }); + + /** + * @var array{'ui': array, 'notification': array, 'logs': array} $resolved + */ + $resolved = $resolver->resolve($options); + + return $resolved; + } + + /** + * @param array $notification + * + * @return array + */ + private static function resolveNotificationOptions(array $notification): array + { + $notificationResolver = new OptionsResolver(); + $notificationResolver->setDefaults(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null]); + $notificationResolver->setAllowedTypes('enabled', ['null', 'bool']); + $notificationResolver->setAllowedTypes('statuses', ['null', 'string[]']); + $notificationResolver->setAllowedValues('statuses', static function (?array $statuses): bool { + /** @var string[]|null $statusValues */ + $statusValues = $statuses; + + return null === $statusValues || [] === array_diff($statusValues, NotificationTrigger::values()); + }); + $notificationResolver->setAllowedTypes('channels', ['null', 'string[]']); + $notificationResolver->setAllowedTypes('recipients', ['null', 'array[]']); + $notificationResolver->setNormalizer('recipients', static function (Options $options, ?array $recipients): ?array { + if (null === $recipients) { + return null; + } + $recipientResolver = new OptionsResolver(); + $recipientResolver->setDefaults(['email' => null, 'phone' => null]); + $recipientResolver->setAllowedTypes('email', ['null', 'string']); + $recipientResolver->setAllowedTypes('phone', ['null', 'string']); + + $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { + if (null === $options['email'] && null === $phone) { + throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); } - $recipientResolver = new OptionsResolver(); - $recipientResolver->setDefaults(['email' => null, 'phone' => null]); - $recipientResolver->setAllowedTypes('email', ['null', 'string']); - $recipientResolver->setAllowedTypes('phone', ['null', 'string']); - $recipientResolver->setNormalizer('phone', static function (Options $options, ?string $phone): ?string { - if (null === $options['email'] && null === $phone) { - throw new InvalidOptionsException('A notification recipient must have an "email" or a "phone".'); - } + return $phone; + }); - return $phone; - }); + /** @var array> $recipientRows */ + $recipientRows = $recipients; - /** @var array> $recipientRows */ - $recipientRows = $recipients; + return array_values(array_map($recipientResolver->resolve(...), $recipientRows)); + }); - return array_values(array_map($recipientResolver->resolve(...), $recipientRows)); + return $notificationResolver->resolve($notification); + } + + /** + * @param array $logs + * + * @return array + */ + private static function resolveLogsOptions(array $logs): array + { + $logsResolver = new OptionsResolver(); + $logsResolver->setDefaults(['database_max_days' => null, 'file_max_days' => null]); + foreach (['database_max_days', 'file_max_days'] as $option) { + $logsResolver->setAllowedTypes($option, ['null', 'int']); + $logsResolver->setAllowedValues($option, static fn (?int $days): bool => null === $days || $days >= 1); + } + + return $logsResolver->resolve($logs); + } + + /** + * @param array $ui + * + * @return UiOptions + */ + private static function resolveUiOptions(array $ui, string $processCode): array + { + $uiResolver = new OptionsResolver(); + $uiResolver->setDefaults( + [ + 'source' => null, + 'target' => null, + 'entrypoint_type' => 'text', + 'ui_launch_mode' => 'modal', + 'constraints' => [], + 'run' => null, + 'default' => [], + 'form' => [], + ] + ); + $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); + // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are + // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 + $uiResolver->setAllowedTypes('default', 'array'); + $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { + $defaultResolver = new OptionsResolver(); + $defaultResolver->setDefaults(['input' => null, 'context' => []]); + $defaultResolver->setAllowedTypes('context', 'array[]'); + $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { + $contextResolver = new OptionsResolver(); + $contextResolver->setRequired(['key', 'value']); + + /** @var array> $contextRows */ + $contextRows = $context; + + return array_map($contextResolver->resolve(...), $contextRows); }); - return $notificationResolver->resolve($notification); + return $defaultResolver->resolve($default); }); - $resolver->setDefault('logs', []); - $resolver->setAllowedTypes('logs', 'array'); - $resolver->setNormalizer('logs', static function (Options $options, array $logs): array { - $logsResolver = new OptionsResolver(); - $logsResolver->setDefaults(['database_max_days' => null, 'file_max_days' => null]); - foreach (['database_max_days', 'file_max_days'] as $option) { - $logsResolver->setAllowedTypes($option, ['null', 'int']); - $logsResolver->setAllowedValues($option, static fn (?int $days): bool => null === $days || $days >= 1); + $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); + $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); + $uiResolver->setAllowedTypes('form', 'array'); + $uiResolver->setNormalizer('form', static fn (Options $options, array $form): array => self::resolveForm($form, $processCode)); + // A Closure message: the deprecation is triggered only when the option is given, once per resolution + $uiResolver->setDeprecated('entrypoint_type', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "entrypoint_type" UI option of the process "%s" is deprecated, use "form.input.type" instead.', $processCode)); + $uiResolver->setDeprecated('default', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "default" UI option of the process "%s" is deprecated, use "form.input.default" and "form.context..default" instead.', $processCode)); + $uiResolver->setDeprecated('constraints', 'cleverage/ui-process-bundle', '3.3', static fn (Options $options, mixed $value): string => \sprintf('The "constraints" UI option of the process "%s" is deprecated, use "form.constraints" instead.', $processCode)); + + /** @var ResolvedUiOptions $resolved */ + $resolved = $uiResolver->resolve($ui); + // The effective "form" option, the deprecated options folded into it + $resolved['form'] = self::foldDeprecatedOptions($ui, $resolved, $processCode); + + /** @var UiOptions $uiOptions */ + $uiOptions = $resolved; + + return $uiOptions; + } + + /** + * @param array $form + * + * @return ResolvedFormOptions + */ + private static function resolveForm(array $form, string $processCode): array + { + $formResolver = new OptionsResolver(); + $formResolver->setDefaults(['input' => null, 'context' => [], 'allow_extra_context' => true, 'constraints' => []]); + $formResolver->setAllowedTypes('input', ['null', 'array']); + $formResolver->setAllowedTypes('context', 'array'); + $formResolver->setAllowedTypes('allow_extra_context', 'bool'); + $formResolver->setAllowedTypes('constraints', 'array'); + $formResolver->setNormalizer( + 'input', + static fn (Options $options, ?array $input): ?array => null === $input ? null : self::resolveField($input, 'form.input', $processCode, true) + ); + $formResolver->setNormalizer('context', static function (Options $options, array $context) use ($processCode): array { + $fields = []; + foreach ($context as $key => $spec) { + if (\is_int($key)) { + throw new InvalidOptionsException(\sprintf('The context key "%d" of "form.context" of the process "%s" is not supported: purely numeric context keys are cast to integers by PHP.', $key, $processCode)); + } + if ('' === $key || !FormConfigBuilder::isValidName($key)) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key, $processCode)); + } + // "extra" is the child of the free context pairs, "execution_user" is set to the email of the connected user + if (\in_array($key, ['extra', ProcessExecuteMessage::EXECUTION_USER_KEY], true)) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "form.context" of the process "%s" is reserved.', $key, $processCode)); + } + if (null !== $spec && !\is_array($spec)) { + throw new InvalidOptionsException(\sprintf('The launch form field "form.context.%s" of the process "%s" must be an array or null, "%s" given.', $key, $processCode, get_debug_type($spec))); + } + $fields[$key] = self::resolveField($spec ?? [], "form.context.$key", $processCode, false); } - return $logsResolver->resolve($logs); + return $fields; }); - $resolver->setDefault('ui', []); - $resolver->setAllowedTypes('ui', 'array'); - $resolver->setNormalizer('ui', static function (Options $options, array $ui): array { - $uiResolver = new OptionsResolver(); - $uiResolver->setDefaults( - [ - 'source' => null, - 'target' => null, - 'entrypoint_type' => 'text', - 'ui_launch_mode' => 'modal', - 'constraints' => [], - 'run' => null, - 'default' => [], - ] - ); - $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); - // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are - // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 - $uiResolver->setAllowedTypes('default', 'array'); - $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { - $defaultResolver = new OptionsResolver(); - $defaultResolver->setDefaults(['input' => null, 'context' => []]); - $defaultResolver->setAllowedTypes('context', 'array[]'); - $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { - $contextResolver = new OptionsResolver(); - $contextResolver->setRequired(['key', 'value']); - - /** @var array> $contextRows */ - $contextRows = $context; - - return array_map($contextResolver->resolve(...), $contextRows); - }); - - return $defaultResolver->resolve($default); - }); - $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); - $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); + $formResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); - return $uiResolver->resolve($ui); + /** @var ResolvedFormOptions $resolved */ + $resolved = $formResolver->resolve($form); + + return $resolved; + } + + /** + * @param array $spec + * + * @return FormFieldOptions + */ + private static function resolveField(array $spec, string $path, string $processCode, bool $isInput): array + { + $fieldResolver = new OptionsResolver(); + $fieldResolver->setDefaults(['type' => 'text', 'options' => [], 'default' => null]); + $fieldResolver->setAllowedTypes('type', 'string'); + $fieldResolver->setAllowedTypes('options', 'array'); + $fieldResolver->setNormalizer('type', static fn (Options $options, string $type): string => self::resolveFormType($type, $path, $processCode)); + $fieldResolver->setNormalizer('options', static function (Options $options, array $fieldOptions) use ($path, $processCode): array { + foreach (self::UNSUPPORTED_FIELD_OPTIONS as $option) { + if (\array_key_exists($option, $fieldOptions)) { + throw new InvalidOptionsException(\sprintf('The "%s" form option of the launch form field "%s" of the process "%s" is not supported, use "default" for the initial value.', $option, $path, $processCode)); + } + } + if (\array_key_exists('constraints', $fieldOptions)) { + if (!\is_array($fieldOptions['constraints'])) { + throw new InvalidOptionsException(\sprintf('The "constraints" form option of the launch form field "%s" of the process "%s" must be an array of constraints.', $path, $processCode)); + } + $fieldOptions['constraints'] = (new ConstraintLoader())->buildConstraints($fieldOptions['constraints']); + } + + return $fieldOptions; }); - /** - * @var array{'ui': UiOptions, 'notification': NotificationOptions, 'logs': LogsOptions} $options - */ - $options = $resolver->resolve($options); - return $options; + /** @var FormFieldOptions $resolved */ + $resolved = $fieldResolver->resolve($spec); + $field = ['type' => $resolved['type'], 'options' => $resolved['options'], 'default' => $resolved['default']]; + + if (is_a($field['type'], Type\FileType::class, true)) { + // A temporary file, removed at the end of the request, would end as a string in the context + if (!$isInput) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field: file fields are only supported for the input.', $path, $processCode)); + } + if (null !== $field['default']) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" is a file field, it cannot have a default value.', $path, $processCode)); + } + // The launch action handles a single uploaded file + if (true === ($field['options']['multiple'] ?? false)) { + throw new InvalidOptionsException(\sprintf('The launch form field "%s" of the process "%s" cannot upload multiple files.', $path, $processCode)); + } + } + + return $field; + } + + /** + * @return class-string> + */ + private static function resolveFormType(string $type, string $path, string $processCode): string + { + $class = self::FORM_TYPE_ALIASES[$type] ?? ltrim($type, '\\'); + if (!class_exists($class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is neither a form type alias (%s) nor an existing class.', $type, $path, $processCode, implode(', ', array_map(static fn (string $alias): string => \sprintf('"%s"', $alias), array_keys(self::FORM_TYPE_ALIASES))))); + } + if (!is_subclass_of($class, FormTypeInterface::class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" must implement "%s".', $type, $path, $processCode, FormTypeInterface::class)); + } + // SubmitType implements SubmitButtonTypeInterface, which does not extend ButtonTypeInterface + if (is_subclass_of($class, ButtonTypeInterface::class) || is_subclass_of($class, SubmitButtonTypeInterface::class)) { + throw new InvalidOptionsException(\sprintf('The type "%s" of the launch form field "%s" of the process "%s" is a button type, it holds no data.', $type, $path, $processCode)); + } + + return $class; + } + + /** + * Checks the conflicts between the deprecated UI options and the "form" option, and folds the deprecated options + * into the effective "form" option. + * + * @param array $ui the raw UI options, to know whether an option is given + * @param ResolvedUiOptions $resolved the resolved UI options, before the folding + * + * @return FormOptions + */ + private static function foldDeprecatedOptions(array $ui, array $resolved, string $processCode): array + { + /** @var array $rawForm */ + $rawForm = $ui['form'] ?? []; + /** @var array|null $rawInput */ + $rawInput = $rawForm['input'] ?? null; + /** @var array|null> $rawContext */ + $rawContext = $rawForm['context'] ?? []; + /** @var array $rawDefault */ + $rawDefault = $ui['default'] ?? []; + $form = $resolved['form']; + + if (\array_key_exists('entrypoint_type', $ui) && null !== $rawInput) { + throw new InvalidOptionsException(\sprintf('The "entrypoint_type" and "form.input" UI options of the process "%s" cannot be used together, use "form.input" only.', $processCode)); + } + if (\array_key_exists('constraints', $ui) && \array_key_exists('constraints', $rawForm)) { + throw new InvalidOptionsException(\sprintf('The "constraints" and "form.constraints" UI options of the process "%s" cannot be used together, use "form.constraints" only.', $processCode)); + } + $hasDefaultInput = \array_key_exists('input', $rawDefault); + if ($hasDefaultInput && \array_key_exists('default', $rawInput ?? [])) { + throw new InvalidOptionsException(\sprintf('The default value of the input of the process "%s" is set both in "default.input" and in "form.input.default", use "form.input.default" only.', $processCode)); + } + // The last row of a key wins + $defaultContext = array_column($resolved['default']['context'], 'value', 'key'); + foreach (array_keys($defaultContext) as $key) { + $key = (string) $key; + if (\array_key_exists($key, $form['context'])) { + if (\array_key_exists('default', $rawContext[$key] ?? [])) { + throw new InvalidOptionsException(\sprintf('The default value of the context key "%1$s" of the process "%2$s" is set both in "default.context" and in "form.context.%1$s.default", use "form.context.%1$s.default" only.', $key, $processCode)); + } + } elseif (false === $form['allow_extra_context']) { + throw new InvalidOptionsException(\sprintf('The context key "%s" of "default.context" of the process "%s" is not declared in "form.context", and "form.allow_extra_context" is false.', $key, $processCode)); + } + } + + $input = $form['input']; + if (null === $input) { + $isFile = 'file' === $resolved['entrypoint_type']; + $input = ['type' => $isFile ? Type\FileType::class : Type\TextType::class, 'options' => [], 'default' => $isFile ? null : $resolved['default']['input']]; + } elseif ($hasDefaultInput && !\array_key_exists('default', $rawInput ?? []) && !is_a($input['type'], Type\FileType::class, true)) { + $input['default'] = $resolved['default']['input']; + } + + $context = $form['context']; + foreach ($context as $key => $field) { + if (\array_key_exists($key, $defaultContext)) { + $context[$key]['default'] = $defaultContext[$key]; + } + } + + return [ + 'input' => $input, + 'context' => $context, + 'allow_extra_context' => $form['allow_extra_context'], + 'constraints' => \array_key_exists('constraints', $rawForm) ? $form['constraints'] : $resolved['constraints'], + ]; } /** @return ProcessConfiguration[] */ diff --git a/src/Message/ProcessExecuteMessage.php b/src/Message/ProcessExecuteMessage.php index 2c69dbe..cff8991 100644 --- a/src/Message/ProcessExecuteMessage.php +++ b/src/Message/ProcessExecuteMessage.php @@ -15,6 +15,11 @@ readonly class ProcessExecuteMessage { + /** + * The context key set to the email of the connected user when a process is launched from the UI. + */ + public const EXECUTION_USER_KEY = 'execution_user'; + /** * @param array $context */ diff --git a/tests/App/Form/EntityIdType.php b/tests/App/Form/EntityIdType.php new file mode 100644 index 0000000..7f41f11 --- /dev/null +++ b/tests/App/Form/EntityIdType.php @@ -0,0 +1,43 @@ + + */ +class EntityIdType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer( + static fn (mixed $entity): string => $entity instanceof Entity ? (string) $entity->id : '', + static fn (mixed $id): ?Entity => \is_string($id) && '' !== $id ? new Entity((int) $id) : null, + )); + } + + #[\Override] + public function getParent(): string + { + return TextType::class; + } +} diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index 600e425..1d68150 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -93,6 +93,130 @@ clever_age_process: tasks: data: service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_fields: + description: 'Process launched with the UI form, with declared context fields' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + delimiter: + type: choice + default: ',' + options: + choices: { 'Comma': ',', 'Semicolon': ';' } + dry_run: + type: checkbox + options: { required: false } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_fields_date: + description: 'Process launched with the UI form, with declared date fields' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + from: + type: date + options: { widget: single_text, input: datetime_immutable, required: false } + to: + type: date + default: '2026-01-02' + options: { widget: single_text, input: string } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_upload: + description: 'Process launched with the UI form, declared file input and context fields' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + input: + type: file + options: + constraints: + - File: + # A CSV file is often detected as "text/plain" (semicolon delimiter, few lines): allow it explicitly + extensions: + csv: ['text/csv', 'text/plain', 'application/csv', 'text/x-csv', 'text/x-comma-separated-values'] + context: + delimiter: + type: choice + default: ',' + options: + choices: { 'Comma': ',', 'Semicolon': ';' } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_strict: + description: 'Process launched with the UI form, without free context' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + delimiter: + default: ',' + allow_extra_context: false + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_global_constraints: + description: 'Process launched with the UI form, with global constraints on a declared context field' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + code: ~ + constraints: + - Collection: + fields: + input: ~ + context: + - Collection: + allowExtraFields: true + fields: + code: + - NotBlank: ~ + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_enum: + description: 'Process launched with the UI form, with a pure enum field' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + choice: + type: enum + options: { class: CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum } + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' + test.form_object: + description: 'Process launched with the UI form, with a field returning an object that is not stringable' + entry_point: data + options: + ui: + ui_launch_mode: form + form: + context: + entity: + type: CleverAge\UiProcessBundle\Tests\App\Form\EntityIdType + tasks: + data: + service: '@CleverAge\ProcessBundle\Task\DummyTask' test.upload: description: 'Process launched with the UI form, file input' entry_point: data diff --git a/tests/Controller/Admin/Process/LaunchActionTest.php b/tests/Controller/Admin/Process/LaunchActionTest.php new file mode 100644 index 0000000..7b918c8 --- /dev/null +++ b/tests/Controller/Admin/Process/LaunchActionTest.php @@ -0,0 +1,74 @@ + */ + public array $messages = []; + + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->messages[] = $message; + + return new Envelope($message, $stamps); + } + }; + $requestStack = new RequestStack(); + $action = new class($bus, $requestStack, new ProcessConfigurationsManager(new ProcessConfigurationRegistry([], 'stop')), new AdminContextProvider($requestStack)) extends LaunchAction { + /** + * @param array $context + */ + public function launch(string $processCode, mixed $input, array $context): void + { + $this->dispatch($processCode, $input, $context); + } + + protected function getUser(): User + { + return (new User())->setEmail('user@example.com'); + } + }; + + $action->launch('demo.process', 'data.csv', ['foo' => 'bar', 'execution_user' => 'someone@example.com']); + + self::assertCount(1, $bus->messages); + $message = $bus->messages[0]; + self::assertInstanceOf(ProcessExecuteMessage::class, $message); + self::assertSame(['execution_user' => 'user@example.com', 'foo' => 'bar'], $message->context); + } +} diff --git a/tests/Form/ContextValueNormalizerTest.php b/tests/Form/ContextValueNormalizerTest.php new file mode 100644 index 0000000..43517f4 --- /dev/null +++ b/tests/Form/ContextValueNormalizerTest.php @@ -0,0 +1,104 @@ +normalize($value, 'key')); + } + + /** + * @return iterable + */ + public static function provideNormalizedValues(): iterable + { + yield 'null' => [null, null]; + yield 'true' => [true, true]; + yield 'integer' => [0, 0]; + yield 'float' => [1.5, 1.5]; + yield 'string' => ['a', 'a']; + yield 'nested array' => [ + ['a' => [1 => StringBackedEnum::Comma, 'b' => true], 'c' => PureEnum::Foo], + ['a' => [1 => ',', 'b' => true], 'c' => 'Foo'], + ]; + yield 'string backed enum' => [StringBackedEnum::Comma, ',']; + yield 'int backed enum' => [IntBackedEnum::Ten, 10]; + yield 'pure enum' => [PureEnum::Foo, 'Foo']; + $timezone = new \DateTimeZone('Europe/Paris'); + yield 'immutable date' => [new \DateTimeImmutable('2026-01-02 03:04:05', $timezone), '2026-01-02T03:04:05+01:00']; + yield 'mutable date' => [new \DateTime('2026-01-02 03:04:05', $timezone), '2026-01-02T03:04:05+01:00']; + // The data of a DateIntervalType with its default "input" option + yield 'date interval' => [new \DateInterval('P1Y2M3DT4H5M6S'), 'P1Y2M3DT4H5M6S']; + yield 'date interval with zero parts' => [new \DateInterval('P1D'), 'P0Y0M1DT0H0M0S']; + $inverted = new \DateInterval('PT1H'); + $inverted->invert = 1; + yield 'inverted date interval' => [$inverted, '-P0Y0M0DT1H0M0S']; + yield 'stringable' => [new class implements \Stringable { + public function __toString(): string + { + return 'stringable'; + } + }, 'stringable']; + // The data of an EntityType is the entity itself + yield 'stringable entity' => [new StringableEntity(42), '42']; + } + + public function testUploadedFile(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('The value of "data" is a file ("%s"): file fields are only supported for the input.', UploadedFile::class)); + (new ContextValueNormalizer())->normalize(new UploadedFile(__FILE__, 'data.csv', null, null, true), 'data'); + } + + public function testFile(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The value of "data" is a file ("SplFileInfo"): file fields are only supported for the input.'); + (new ContextValueNormalizer())->normalize(new \SplFileInfo(__FILE__), 'data'); + } + + #[DataProvider('provideInvalidValues')] + public function testInvalidValue(mixed $value, string $name, string $debugType): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('The value of "%s" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', $name, $debugType)); + (new ContextValueNormalizer())->normalize($value, 'k'); + } + + /** + * @return iterable + */ + public static function provideInvalidValues(): iterable + { + yield 'object' => [new \stdClass(), 'k', 'stdClass']; + // The context ['k' => [0 => object]]: the value of the key "k" is normalized with the name "k" + yield 'nested object' => [[0 => new \stdClass()], 'k[0]', 'stdClass']; + yield 'entity' => [new Entity(42), 'k', Entity::class]; + } +} diff --git a/tests/Form/Fixtures/Entity.php b/tests/Form/Fixtures/Entity.php new file mode 100644 index 0000000..ee96581 --- /dev/null +++ b/tests/Form/Fixtures/Entity.php @@ -0,0 +1,24 @@ +id; + } +} diff --git a/tests/Form/Type/LaunchTypeTest.php b/tests/Form/Type/LaunchTypeTest.php index ee2ce68..e673eea 100644 --- a/tests/Form/Type/LaunchTypeTest.php +++ b/tests/Form/Type/LaunchTypeTest.php @@ -18,17 +18,25 @@ use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Validator\ValidatorExtension; use Symfony\Component\Form\FormExtensionInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\PreloadedExtension; use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Validation; #[CoversClass(LaunchType::class)] @@ -51,6 +59,22 @@ protected function getExtensions(): array $registry = new ProcessConfigurationRegistry([ 'text.process' => $this->rawProcess(), 'file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data'), + 'fields.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'form', 'form' => [ + 'input' => ['options' => ['required' => false]], + 'context' => [ + 'delimiter' => [ + 'type' => 'choice', + 'default' => ',', + 'options' => ['label' => 'Delimiter', 'choices' => ['Comma' => ',', 'Semicolon' => ';']], + ], + 'dry_run' => ['type' => 'checkbox', 'default' => false, 'options' => ['required' => false]], + 'limit' => ['type' => 'integer', 'options' => ['constraints' => [['Positive' => null]]]], + ], + ]]], 'data'), + 'strict.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'form', 'form' => [ + 'context' => ['delimiter' => null], + 'allow_extra_context' => false, + ]]]), ], 'stop'); return [ @@ -66,17 +90,29 @@ public function testTextInput(): void self::assertInstanceOf(TextType::class, $this->getInnerType($form->get('input'))); self::assertFalse($form->get('input')->getConfig()->getRequired(), 'The process has no entry point'); - $context = $form->get('context')->getConfig(); - self::assertInstanceOf(CollectionType::class, $context->getType()->getInnerType()); - self::assertSame(ProcessContextType::class, $context->getOption('entry_type')); - self::assertTrue($context->getOption('allow_add')); - self::assertTrue($context->getOption('allow_delete')); - self::assertFalse($context->getOption('required')); + $context = $form->get('context'); + self::assertInstanceOf(FormType::class, $this->getInnerType($context)); + self::assertSame(['extra'], array_keys($context->all())); + $extra = $context->get('extra')->getConfig(); + self::assertInstanceOf(CollectionType::class, $extra->getType()->getInnerType()); + self::assertSame(ProcessContextType::class, $extra->getOption('entry_type')); + self::assertTrue($extra->getOption('allow_add')); + self::assertTrue($extra->getOption('allow_delete')); + self::assertFalse($extra->getOption('required')); + self::assertFalse($extra->getOption('mapped')); + self::assertFalse($extra->getOption('label')); + self::assertSame([], $this->getReservedKeys($extra->getOption('entry_options'))); } public function testFileInput(): void { - $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process']); + $form = null; + $deprecations = $this->collectDeprecations(function () use (&$form): void { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process']); + }); + self::assertInstanceOf(FormInterface::class, $form); + + self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "file.process" is deprecated, use "form.input.type" instead.'], $deprecations); self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); self::assertTrue($form->get('input')->getConfig()->getRequired(), 'The process has an entry point'); @@ -87,10 +123,10 @@ public function testSubmit(): void $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); $form->submit([ 'input' => 'data.csv', - 'context' => [ + 'context' => ['extra' => [ ['key' => 'foo', 'value' => 'bar'], ['key' => 'baz', 'value' => 'qux'], - ], + ]], ]); self::assertTrue($form->isSynchronized()); @@ -117,7 +153,7 @@ public function testDefaultData(): void public function testInvalidContext(): void { $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); - $form->submit(['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => '']]]); + $form->submit(['input' => 'data.csv', 'context' => ['extra' => [['key' => 'foo', 'value' => '']]]]); self::assertTrue($form->isSubmitted()); self::assertFalse($form->isValid()); @@ -126,12 +162,184 @@ public function testInvalidContext(): void public function testContextRowWithoutKey(): void { $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process']); - $form->submit(['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar'], ['key' => null, 'value' => 'qux']]]); + $form->submit(['input' => 'data.csv', 'context' => ['extra' => [['key' => 'foo', 'value' => 'bar'], ['key' => null, 'value' => 'qux']]]]); // The row without key is left out of the context, and reported as invalid self::assertSame(['foo' => 'bar'], $form->get('context')->getData()); self::assertFalse($form->isValid()); - self::assertCount(1, $form->get('context')->get('1')->get('key')->getErrors()); + self::assertCount(1, $form->get('context')->get('extra')->get('1')->get('key')->getErrors()); + } + + public function testDeclaredFields(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $context = $form->get('context'); + + self::assertSame(['delimiter', 'dry_run', 'limit', 'extra'], array_keys($context->all())); + $delimiter = $context->get('delimiter')->getConfig(); + self::assertInstanceOf(ChoiceType::class, $delimiter->getType()->getInnerType()); + self::assertSame(['Comma' => ',', 'Semicolon' => ';'], $delimiter->getOption('choices')); + self::assertSame('Delimiter', $delimiter->getOption('label')); + self::assertInstanceOf(CheckboxType::class, $this->getInnerType($context->get('dry_run'))); + self::assertInstanceOf(IntegerType::class, $this->getInnerType($context->get('limit'))); + $extra = $context->get('extra')->getConfig(); + self::assertSame('Context (key/value)', $extra->getOption('label')); + self::assertSame(['delimiter', 'dry_run', 'limit'], $this->getReservedKeys($extra->getOption('entry_options'))); + } + + public function testSubmitDeclaredAndExtraContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->submit(['input' => '', 'context' => [ + 'delimiter' => ';', + 'dry_run' => '1', + 'limit' => '3', + 'extra' => [['key' => 'k2', 'value' => 'v2']], + ]]); + + self::assertTrue($form->isValid()); + self::assertSame(['delimiter' => ';', 'dry_run' => true, 'limit' => 3, 'k2' => 'v2'], $form->get('context')->getData()); + } + + public function testExtraContextRowWithADeclaredKey(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->submit(['context' => [ + 'delimiter' => ';', + 'extra' => [['key' => 'k2', 'value' => 'v2'], ['key' => 'delimiter', 'value' => 'zz']], + ]]); + + self::assertFalse($form->isValid()); + $errors = $form->get('context')->get('extra')->get('1')->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "delimiter" is a field of the launch form, use this field instead.', $errors[0]->getMessage()); + self::assertCount(0, $form->get('context')->get('delimiter')->getErrors()); + } + + #[TestWith(['text.process'])] + #[TestWith(['fields.process'])] + public function testExtraContextRowWithTheExecutionUserKey(string $processCode): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => $processCode]); + $form->submit(['context' => ['extra' => [['key' => 'execution_user', 'value' => 'someone@example.com']]]]); + + self::assertFalse($form->isValid()); + $errors = $form->get('context')->get('extra')->get('0')->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "execution_user" is reserved: it is set to the email of the connected user.', $errors[0]->getMessage()); + } + + public function testFieldConstraint(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->submit(['context' => ['delimiter' => ',', 'limit' => '-1']]); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get('context')->get('limit')->getErrors()); + } + + public function testGlobalConstraintOnADeclaredField(): void + { + $form = $this->factory->create(LaunchType::class, null, [ + 'process_code' => 'fields.process', + 'constraints' => [new Collection( + fields: ['context' => new Collection(fields: ['delimiter' => new Choice(choices: [','])], allowExtraFields: true)], + allowExtraFields: true, + )], + ]); + $form->submit(['context' => ['delimiter' => ';']]); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get('context')->get('delimiter')->getErrors()); + } + + public function testExtraContextNotAllowed(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'strict.process']); + + self::assertSame(['delimiter'], array_keys($form->get('context')->all())); + + $form->submit(['context' => ['delimiter' => ',', 'extra' => [['key' => 'k', 'value' => 'v']]]]); + self::assertFalse($form->isValid()); + } + + /** + * Defensive: free keys in the data of a process without free context are not turned into rows, and are left out + * of the submitted context. + */ + public function testFreeDefaultsWithoutExtraContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'strict.process']); + $form->setData(['input' => null, 'context' => ['delimiter' => ';', 'foo' => 'bar']]); + + self::assertSame(';', $form->get('context')->get('delimiter')->getData()); + self::assertFalse($form->get('context')->has('extra')); + + $form->submit(['context' => ['delimiter' => ',']]); + self::assertTrue($form->isValid()); + self::assertSame(['delimiter' => ','], $form->get('context')->getData()); + } + + public function testDefaultsOfDeclaredAndExtraContext(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + $form->setData(['input' => null, 'context' => ['delimiter' => ';', 'dry_run' => true, 'limit' => null, 'foo' => 'bar']]); + + $context = $form->get('context'); + self::assertSame(';', $context->get('delimiter')->getData()); + self::assertSame([['key' => 'foo', 'value' => 'bar']], $context->get('extra')->getData()); + + $form->submit(['context' => ['delimiter' => ',', 'extra' => []]]); + self::assertTrue($form->isValid()); + self::assertSame(['delimiter' => ',', 'dry_run' => false, 'limit' => null], $context->getData()); + } + + public function testRequiredOptionOfTheDeclaredFields(): void + { + $view = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process'])->createView(); + $context = $view->children['context']; + + self::assertFalse($context->vars['required'], 'The context group is not rendered as required'); + self::assertTrue($context->children['delimiter']->vars['required'], 'Symfony default'); + self::assertTrue($context->children['limit']->vars['required'], 'Symfony default'); + self::assertFalse($context->children['dry_run']->vars['required'], 'The required option of the field is configured'); + self::assertFalse($context->children['extra']->vars['required'], 'The free context pairs are not required'); + } + + public function testRequiredOptionOfTheInput(): void + { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'fields.process']); + + self::assertFalse($form->get('input')->getConfig()->getRequired(), 'The required option of the input is configured'); + } + + /** + * The UI options already resolved by the caller are not resolved again. + */ + public function testResolvedUiOptions(): void + { + $registry = new ProcessConfigurationRegistry(['file.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'file']], 'data')], 'stop'); + $uiOptions = null; + $resolutionDeprecations = $this->collectDeprecations(static function () use ($registry, &$uiOptions): void { + $uiOptions = (new ProcessConfigurationsManager($registry))->getUiOptions('file.process'); + }); + self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "file.process" is deprecated, use "form.input.type" instead.'], $resolutionDeprecations); + self::assertIsArray($uiOptions); + + $form = null; + $deprecations = $this->collectDeprecations(function () use (&$form, $uiOptions): void { + $form = $this->factory->create(LaunchType::class, null, ['process_code' => 'file.process', 'ui_options' => $uiOptions]); + }); + + self::assertSame([], $deprecations); + self::assertInstanceOf(FormInterface::class, $form); + self::assertInstanceOf(FileType::class, $this->getInnerType($form->get('input'))); + } + + public function testUiOptionsMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(LaunchType::class, null, ['process_code' => 'text.process', 'ui_options' => 'form']); } public function testProcessCodeIsRequired(): void @@ -140,6 +348,33 @@ public function testProcessCodeIsRequired(): void $this->factory->create(LaunchType::class); } + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private function getReservedKeys(mixed $entryOptions): mixed + { + self::assertIsArray($entryOptions); + + return $entryOptions['reserved_keys'] ?? null; + } + /** * @param FormInterface $form */ diff --git a/tests/Form/Type/ProcessContextTypeTest.php b/tests/Form/Type/ProcessContextTypeTest.php index 1484ebb..cefb1ff 100644 --- a/tests/Form/Type/ProcessContextTypeTest.php +++ b/tests/Form/Type/ProcessContextTypeTest.php @@ -20,6 +20,8 @@ use Symfony\Component\Form\Extension\Validator\ValidatorExtension; use Symfony\Component\Form\FormExtensionInterface; use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Validation; @@ -51,9 +53,11 @@ public function testFields(): void self::assertSame(['placeholder' => $name], $config->getOption('attr')); $constraints = $config->getOption('constraints'); self::assertIsArray($constraints); - self::assertCount(1, $constraints); self::assertInstanceOf(NotBlank::class, $constraints[0]); } + // The format of the key is checked + self::assertCount(2, (array) $form->get('key')->getConfig()->getOption('constraints')); + self::assertCount(1, (array) $form->get('value')->getConfig()->getOption('constraints')); } public function testSubmitValidData(): void @@ -76,4 +80,104 @@ public function testKeyAndValueAreRequired(string $key, string $value, string $i self::assertFalse($form->isValid()); self::assertCount(1, $form->get($invalidField)->getErrors()); } + + public function testNoReservedKeysByDefault(): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => 'delimiter', 'value' => ';']); + + self::assertSame([], $form->getConfig()->getOption('reserved_keys')); + self::assertTrue($form->isValid()); + } + + public function testReservedKey(): void + { + $form = $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => ['delimiter']]); + $form->submit(['key' => 'delimiter', 'value' => ';']); + + self::assertFalse($form->isValid()); + $errors = $form->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "delimiter" is a field of the launch form, use this field instead.', $errors[0]->getMessage()); + self::assertCount(0, $form->get('value')->getErrors()); + } + + #[TestWith(['other'])] + #[TestWith(['Delimiter'])] + public function testKeyIsNotReserved(string $key): void + { + $form = $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => ['delimiter']]); + $form->submit(['key' => $key, 'value' => ';']); + + self::assertTrue($form->isValid()); + } + + public function testReservedKeysMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(ProcessContextType::class, null, ['reserved_keys' => 'delimiter']); + } + + #[TestWith(['foo'])] + #[TestWith(['Delimiter'])] + #[TestWith(['a-b:c'])] + #[TestWith(['_x'])] + #[TestWith(['1a'])] + #[TestWith(['dry_run'])] + public function testValidKeyFormat(string $key): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => $key, 'value' => 'bar']); + + self::assertTrue($form->isValid()); + } + + #[TestWith(['zz)|x|(?:zz'])] + #[TestWith(['a/b'])] + #[TestWith(['a b'])] + #[TestWith(['{{x}}'])] + #[TestWith(['-a'])] + #[TestWith(['123'])] + public function testInvalidKeyFormat(string $key): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => $key, 'value' => 'bar']); + + self::assertFalse($form->isValid()); + $errors = $form->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame( + \sprintf('The context key "%s" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.', $key), + $errors[0]->getMessage() + ); + } + + public function testEmptyKey(): void + { + $form = $this->factory->create(ProcessContextType::class); + $form->submit(['key' => '', 'value' => 'bar']); + + self::assertFalse($form->isValid()); + // Reported by the NotBlank constraint only + self::assertCount(1, $form->get('key')->getErrors()); + } + + public function testKeyConstraints(): void + { + $form = $this->factory->create(ProcessContextType::class, null, [ + 'key_constraints' => [new Choice(choices: ['execution_user'], message: 'Reserved key {{ value }}.', match: false)], + ]); + $form->submit(['key' => 'execution_user', 'value' => 'bar']); + + self::assertFalse($form->isValid()); + $errors = $form->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('Reserved key "execution_user".', $errors[0]->getMessage()); + } + + public function testKeyConstraintsMustBeConstraints(): void + { + $this->expectException(InvalidOptionsException::class); + $this->factory->create(ProcessContextType::class, null, ['key_constraints' => ['NotBlank']]); + } } diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index be21465..f1c4093 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -25,6 +25,7 @@ use CleverAge\UiProcessBundle\DependencyInjection\Configuration; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\EventSubscriber\ProcessEventSubscriber; +use CleverAge\UiProcessBundle\Form\ContextValueNormalizer; use CleverAge\UiProcessBundle\Form\Type\LaunchType; use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; @@ -33,6 +34,8 @@ use CleverAge\UiProcessBundle\Monolog\Handler\ProcessHandler; use CleverAge\UiProcessBundle\Repository\ProcessExecutionRepository; use CleverAge\UiProcessBundle\Security\HttpProcessExecutionAuthenticator; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\Entity; +use CleverAge\UiProcessBundle\Tests\Form\Fixtures\PureEnum; use CleverAge\UiProcessBundle\Twig\Extension\LogLevelExtension; use CleverAge\UiProcessBundle\Twig\Extension\MD5Extension; use CleverAge\UiProcessBundle\Twig\Extension\ProcessExecutionExtension; @@ -41,6 +44,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)] @@ -49,6 +53,7 @@ #[UsesClass(User::class)] #[UsesClass(LaunchType::class)] #[UsesClass(ProcessContextType::class)] +#[UsesClass(ContextValueNormalizer::class)] #[UsesClass(ProcessConfigurationsManager::class)] #[UsesClass(ProcessExecuteMessage::class)] #[UsesClass(ProcessExecutionRepository::class)] @@ -99,13 +104,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(); @@ -116,6 +121,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=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(); @@ -162,7 +190,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) @@ -171,6 +199,356 @@ public function testContextRowWithoutKey(): void self::assertSame([], $this->getDispatchedMessages()); } + /** + * The deprecated format is unchanged: same initial values, same dispatched input and context (only the HTML names + * of the free rows change, "launch[context][extra][N]"). + */ + public function testDeprecatedFormatIsUnchanged(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=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=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=process_launch&process=test.form_fields'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['delimiter' => '|']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame('The selected choice is invalid.', $this->getFieldError($crawler, 'launch_context_delimiter')); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testExtraContextRowWithADeclaredKey(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.form_fields'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['delimiter' => ';', 'extra' => [['key' => 'delimiter', 'value' => 'zz']]]; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame( + 'The context key "delimiter" is a field of the launch form, use this field instead.', + $this->getFieldError($crawler, 'launch_context_extra_0_key') + ); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testExtraContextRowWithTheExecutionUserKey(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.form'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['extra' => [['key' => 'execution_user', 'value' => 'someone@example.com']]]; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + self::assertSame( + 'The context key "execution_user" is reserved: it is set to the email of the connected user.', + $this->getFieldError($crawler, 'launch_context_extra_0_key') + ); + self::assertSame([], $this->getDispatchedMessages()); + } + + public function testDateFields(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=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=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=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=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=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=process_launch&process=test.form_global_constraints'); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['code' => '']; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseIsSuccessful(); + // The violation of the "form.constraints" option is displayed under the declared field + self::assertSame('This value should not be blank.', $this->getFieldError($crawler, 'launch_context_code')); + self::assertSame([], $this->getDispatchedMessages()); + + $values['launch']['context'] = ['code' => 'ABC']; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + self::assertSame(['execution_user' => 'user@example.com', 'code' => 'ABC'], $messages[0]->context); + } + + public function testPureEnumField(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.form_enum'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['choice' => $crawler->filter('select[name="launch[context][choice]"] option')->attr('value')]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $messages = $this->getDispatchedMessages(); + self::assertCount(1, $messages); + // The name of the case of the pure enum + self::assertSame(['execution_user' => 'user@example.com', 'choice' => PureEnum::Foo->name], $messages[0]->context); + } + + /** + * A field whose data is an object that cannot be converted (as an EntityType on an entity that is not + * \Stringable): the launch fails with a LogicException (a 500), nothing is dispatched. + */ + public function testContextValueThatCannotBeConverted(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.form_object'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Launch')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['launch']['context'] = ['entity' => '42']; + + $this->client->catchExceptions(false); + try { + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::fail('A LogicException is expected'); + } catch (\LogicException $exception) { + self::assertSame( + \sprintf('The value of "entity" ("%s") cannot be used as a process context value: use a form type returning a scalar (e.g. "choice" with scalar choices), or make the class implement \Stringable.', Entity::class), + $exception->getMessage() + ); + } + self::assertSame([], $this->getDispatchedMessages()); + + // Caught by the kernel: a 500 + $this->client->catchExceptions(true); + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseStatusCodeSame(500); + self::assertSame([], $this->getDispatchedMessages()); + } + + /** + * The UI options are resolved once per launch request: the deprecations of the old format are triggered once. + */ + public function testDeprecationsOfTheLaunchForm(): void + { + $this->login(); + + $deprecations = $this->collectDeprecations(fn () => $this->client->request('GET', '/process?routeName=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=process_launch&process=test.form_fields')); + self::assertResponseIsSuccessful(); + self::assertSame([], $deprecations); + } + public function testMissingProcessCode(): void { $this->login(); @@ -205,4 +583,36 @@ public function testDirectAccessRedirectsToTheDashboard(): void self::assertResponseIsSuccessful(); self::assertCount(1, $crawler->filter('input[name="launch[input]"]')); } + + /** + * The error message displayed under the form field with the given id. + */ + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private function getFieldError(Crawler $crawler, string $id): string + { + $errors = $crawler->filter('#'.$id)->closest('.form-widget')?->filter('.invalid-feedback'); + self::assertNotNull($errors); + self::assertCount(1, $errors, \sprintf('One error is expected under the field "%s".', $id)); + + return trim($errors->text()); + } } diff --git a/tests/Functional/ProcessScheduleCrudTest.php b/tests/Functional/ProcessScheduleCrudTest.php index 52c851b..ea53b08 100644 --- a/tests/Functional/ProcessScheduleCrudTest.php +++ b/tests/Functional/ProcessScheduleCrudTest.php @@ -40,8 +40,13 @@ use CleverAge\UiProcessBundle\Validator\CronExpressionValidator; use CleverAge\UiProcessBundle\Validator\EveryExpressionValidator; use CleverAge\UiProcessBundle\Validator\IsValidProcessCodeValidator; +use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\Form\FormFactoryInterface; #[CoversClass(ProcessScheduleCrudController::class)] #[UsesClass(EnumField::class)] @@ -178,6 +183,132 @@ public function testDisable(): void self::assertDoesNotMatchRegularExpression('/\d{4}-\d{2}-\d{2}T04:00:00/', $crawler->filter('table')->text()); } + /** + * The "execution_user" context key is reserved in the schedules too, as in the launch form. + */ + public function testExecutionUserIsAReservedContextKey(): void + { + $controller = static::getContainer()->get(ProcessScheduleCrudController::class); + self::assertInstanceOf(ProcessScheduleCrudController::class, $controller); + $entryOptions = null; + foreach ($controller->configureFields(Crud::PAGE_EDIT) as $field) { + if ($field instanceof FieldInterface && 'context' === $field->getAsDto()->getProperty()) { + $entryOptions = $field->getAsDto()->getFormTypeOptions()['entry_options'] ?? null; + } + } + self::assertIsArray($entryOptions); + /** @var array $rowOptions */ + $rowOptions = $entryOptions + ['csrf_protection' => false]; + + $formFactory = static::getContainer()->get('form.factory'); + self::assertInstanceOf(FormFactoryInterface::class, $formFactory); + $row = $formFactory->create(ProcessContextType::class, null, $rowOptions); + $row->submit(['key' => 'execution_user', 'value' => 'someone@example.com']); + + self::assertFalse($row->isValid()); + $errors = $row->get('key')->getErrors(); + self::assertCount(1, $errors); + self::assertSame('The context key "execution_user" is reserved: it is set to the email of the connected user.', $errors[0]->getMessage()); + } + + /** + * The keys of the context rows follow the same rules as in the launch form. + */ + #[TestWith(['a b', 'The context key "a b" is not valid: use letters, digits, "_", "-" and ":", starting with a letter, a digit or "_", and not only digits.'])] + #[TestWith(['execution_user', 'The context key "execution_user" is reserved: it is set to the email of the connected user.'])] + public function testCreateWithAnInvalidContextKey(string $key, string $error): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process/process-schedule/new'); + $form = $crawler->selectButton('Create')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['ProcessSchedule']['process'] = 'test.process'; + $values['ProcessSchedule']['type'] = 'cron'; + $values['ProcessSchedule']['expression'] = '*/5 * * * *'; + $values['ProcessSchedule']['context'] = [['key' => 'delimiter', 'value' => ';'], ['key' => $key, 'value' => 'value']]; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseStatusCodeSame(422); + self::assertSame([$error], $this->getFieldErrors($crawler, 'ProcessSchedule_context_1_key')); + self::assertSame([], $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key')); + self::assertSame([], $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll()); + } + + public function testCreateWithAValidContextKey(): void + { + $this->login(); + + $crawler = $this->client->request('GET', '/process/process-schedule/new'); + $form = $crawler->selectButton('Create')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['ProcessSchedule']['process'] = 'test.process'; + $values['ProcessSchedule']['type'] = 'cron'; + $values['ProcessSchedule']['expression'] = '*/5 * * * *'; + $values['ProcessSchedule']['context'] = [['key' => 'delimiter', 'value' => ';']]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + + self::assertResponseRedirects(); + $this->getEntityManager()->clear(); + $schedules = $this->getEntityManager()->getRepository(ProcessSchedule::class)->findAll(); + self::assertCount(1, $schedules); + self::assertSame([['key' => 'delimiter', 'value' => ';']], $schedules[0]->getContext()); + } + + /** + * A schedule saved before the rules on the context keys: it is saved again only once its key is fixed, and its + * "enabled" toggle of the index page is not affected. + */ + public function testScheduleWithAContextKeyNowInvalid(): void + { + $this->login(); + $schedule = $this->createSchedule(ProcessScheduleType::CRON, '0 4 * * *'); + $schedule->setContext([['key' => 'execution_user', 'value' => 'someone@example.com']]); + $this->getEntityManager()->flush(); + $id = $schedule->getId(); + + // The toggle of the index page (an AJAX PATCH request): no form validation + $crawler = $this->client->request('GET', '/process/process-schedule'); + self::assertResponseIsSuccessful(); + $toggleUrl = (string) $crawler->filter('[data-toggle-url]')->attr('data-toggle-url'); + self::assertStringContainsString('fieldName=enabled', $toggleUrl); + $this->client->xmlHttpRequest('PATCH', $toggleUrl.'&newValue=false'); + self::assertResponseIsSuccessful(); + $this->getEntityManager()->clear(); + $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); + self::assertNotNull($schedule); + self::assertFalse($schedule->isEnabled()); + self::assertSame([['key' => 'execution_user', 'value' => 'someone@example.com']], $schedule->getContext()); + + // Saved unchanged: refused + $crawler = $this->client->request('GET', '/process/process-schedule/'.$id.'/edit'); + self::assertResponseIsSuccessful(); + $form = $crawler->selectButton('Save changes')->form(); + /** @var array> $values */ + $values = $form->getPhpValues(); + $values['ProcessSchedule']['expression'] = '0 5 * * *'; + $crawler = $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseStatusCodeSame(422); + self::assertSame( + ['The context key "execution_user" is reserved: it is set to the email of the connected user.'], + $this->getFieldErrors($crawler, 'ProcessSchedule_context_0_key') + ); + $this->getEntityManager()->clear(); + self::assertSame('0 4 * * *', $this->getEntityManager()->find(ProcessSchedule::class, $id)?->getExpression()); + + // Saved once the key is fixed + $values['ProcessSchedule']['context'] = [['key' => 'user', 'value' => 'someone@example.com']]; + $this->client->request($form->getMethod(), $form->getUri(), $values); + self::assertResponseRedirects(); + $this->getEntityManager()->clear(); + $schedule = $this->getEntityManager()->find(ProcessSchedule::class, $id); + self::assertNotNull($schedule); + self::assertSame('0 5 * * *', $schedule->getExpression()); + self::assertSame([['key' => 'user', 'value' => 'someone@example.com']], $schedule->getContext()); + } + public function testEntityFqcn(): void { self::assertSame(ProcessSchedule::class, ProcessScheduleCrudController::getEntityFqcn()); @@ -194,4 +325,17 @@ private function createSchedule(ProcessScheduleType $type, string $expression): return $schedule; } + + /** + * The error messages displayed under the form field with the given id. + * + * @return list + */ + private function getFieldErrors(Crawler $crawler, string $id): array + { + $widget = $crawler->filter('#'.$id)->closest('.form-widget'); + self::assertNotNull($widget, \sprintf('No field "%s".', $id)); + + return $widget->filter('.invalid-feedback')->each(static fn (Crawler $error): string => trim($error->text())); + } } diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index 82141d5..269b868 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -14,13 +14,23 @@ namespace CleverAge\UiProcessBundle\Tests\Manager; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; +use CleverAge\ProcessBundle\Exception\MissingProcessException; use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; +use CleverAge\UiProcessBundle\Form\Type\ProcessContextType; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use CleverAge\UiProcessBundle\Notifier\NotificationTrigger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\Extension\Core\Type; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface as OptionsResolverException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; @@ -28,10 +38,54 @@ use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; +/** + * @phpstan-import-type UiOptions from ProcessConfigurationsManager + */ #[CoversClass(ProcessConfigurationsManager::class)] #[UsesClass(NotificationTrigger::class)] class ProcessConfigurationsManagerTest extends TestCase { + /** + * The form type aliases of the launch form fields, in their documented order. + */ + private const FORM_TYPE_ALIASES = [ + 'text' => TextType::class, + 'textarea' => TextareaType::class, + 'email' => Type\EmailType::class, + 'password' => Type\PasswordType::class, + 'search' => Type\SearchType::class, + 'url' => Type\UrlType::class, + 'tel' => Type\TelType::class, + 'hidden' => Type\HiddenType::class, + 'integer' => Type\IntegerType::class, + 'number' => Type\NumberType::class, + 'money' => Type\MoneyType::class, + 'percent' => Type\PercentType::class, + 'range' => Type\RangeType::class, + 'checkbox' => Type\CheckboxType::class, + 'radio' => Type\RadioType::class, + 'choice' => ChoiceType::class, + 'enum' => Type\EnumType::class, + 'country' => Type\CountryType::class, + 'currency' => Type\CurrencyType::class, + 'language' => Type\LanguageType::class, + 'locale' => Type\LocaleType::class, + 'timezone' => Type\TimezoneType::class, + 'date' => Type\DateType::class, + 'datetime' => Type\DateTimeType::class, + 'time' => Type\TimeType::class, + 'birthday' => Type\BirthdayType::class, + 'week' => Type\WeekType::class, + 'dateinterval' => Type\DateIntervalType::class, + 'color' => Type\ColorType::class, + 'file' => FileType::class, + 'uuid' => Type\UuidType::class, + 'ulid' => Type\UlidType::class, + 'collection' => Type\CollectionType::class, + 'repeated' => Type\RepeatedType::class, + 'entity' => EntityType::class, + ]; + public function testPublicAndPrivateProcesses(): void { $manager = $this->createManager([ @@ -60,9 +114,9 @@ public function testUiOptionsOfAnUnknownProcess(): void public function testDefaultUiOptions(): void { - $options = $this->createManager(['test.process' => $this->rawProcess()])->getUiOptions('test.process'); + [$options, $deprecations] = $this->resolveUi([]); - self::assertNotNull($options); + self::assertSame([], $deprecations); self::assertNull($options['source']); self::assertNull($options['target']); self::assertSame('text', $options['entrypoint_type']); @@ -72,30 +126,40 @@ public function testDefaultUiOptions(): void $run = $options['run']; self::assertNull($run); self::assertSame(['input' => null, 'context' => []], $options['default']); + self::assertSame( + [ + 'input' => ['type' => TextType::class, 'options' => [], 'default' => null], + 'context' => [], + 'allow_extra_context' => true, + 'constraints' => [], + ], + $options['form'] + ); } public function testConfiguredUiOptions(): void { - $manager = $this->createManager([ - 'test.process' => $this->rawProcess([ - 'ui' => [ - 'source' => 'source', - 'target' => 'target', - 'entrypoint_type' => 'file', - 'ui_launch_mode' => 'form', - 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], - 'run' => true, - 'default' => [ - 'input' => 'data.csv', - 'context' => [['key' => 'foo', 'value' => 'bar']], - ], - ], - ]), + [$options, $deprecations] = $this->resolveUi([ + 'source' => 'source', + 'target' => 'target', + 'entrypoint_type' => 'file', + 'ui_launch_mode' => 'form', + 'constraints' => [['NotBlank' => null], ['Length' => ['max' => 10]]], + 'run' => true, + 'default' => [ + 'input' => 'data.csv', + 'context' => [['key' => 'foo', 'value' => 'bar']], + ], ]); - $options = $manager->getUiOptions('test.process'); - - self::assertNotNull($options); + self::assertSame( + [ + self::deprecation('entrypoint_type', 'form.input.type'), + self::deprecation('constraints', 'form.constraints'), + self::deprecation('default', 'form.input.default" and "form.context..default'), + ], + $deprecations + ); self::assertSame('source', $options['source']); self::assertSame('target', $options['target']); self::assertSame('file', $options['entrypoint_type']); @@ -108,17 +172,18 @@ public function testConfiguredUiOptions(): void self::assertInstanceOf(NotBlank::class, $options['constraints'][0]); self::assertInstanceOf(Length::class, $options['constraints'][1]); self::assertSame(10, $options['constraints'][1]->max); + // The deprecated options are folded into the effective "form" option + self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $options['form']['input']); + self::assertSame([], $options['form']['context']); + self::assertTrue($options['form']['allow_extra_context']); + self::assertSame($options['constraints'], $options['form']['constraints']); } public function testDefaultWithoutContext(): void { - $manager = $this->createManager([ - 'test.process' => $this->rawProcess(['ui' => ['default' => ['input' => 'data.csv']]]), - ]); - - $options = $manager->getUiOptions('test.process'); + [$options, $deprecations] = $this->resolveUi(['default' => ['input' => 'data.csv']]); - self::assertNotNull($options); + self::assertSame([self::deprecation('default', 'form.input.default" and "form.context..default')], $deprecations); self::assertSame(['input' => 'data.csv', 'context' => []], $options['default']); } @@ -129,10 +194,8 @@ public function testDefaultWithoutContext(): void #[DataProvider('provideInvalidDefault')] public function testInvalidDefault(array $default, string $exception): void { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => $default]])]); - $this->expectException($exception); - $manager->getUiOptions('test.process'); + $this->resolveUi(['default' => $default]); } /** @@ -152,20 +215,578 @@ public static function provideInvalidDefault(): iterable public function testDefaultIsNotAnArray(): void { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => 'data.csv']])]); - $this->expectException(InvalidOptionsException::class); - $manager->getUiOptions('test.process'); + $this->resolveUi(['default' => 'data.csv']); } public function testInvalidEntrypointType(): void { - $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'csv']])]); + $this->expectException(InvalidOptionsException::class); + $this->resolveUi(['entrypoint_type' => 'csv']); + } + + public function testFormInputAlias(): void + { + [$options, $deprecations] = $this->resolveUi(['form' => ['input' => ['type' => 'file', 'options' => ['label' => 'CSV file']]]]); + + self::assertSame([], $deprecations); + self::assertSame(['type' => FileType::class, 'options' => ['label' => 'CSV file'], 'default' => null], $options['form']['input']); + } + + /** + * @param class-string $class + */ + #[DataProvider('provideFormTypeAliases')] + public function testFormTypeAlias(string $alias, string $class): void + { + [$options] = $this->resolveUi(['form' => ['input' => ['type' => $alias]]]); + + self::assertSame($class, $options['form']['input']['type']); + } + + /** + * @return iterable + */ + public static function provideFormTypeAliases(): iterable + { + foreach (self::FORM_TYPE_ALIASES as $alias => $class) { + yield $alias => [$alias, $class]; + } + } + + /** + * @param class-string $class + */ + #[TestWith([ChoiceType::class, ChoiceType::class])] + #[TestWith([ProcessContextType::class, ProcessContextType::class])] + #[TestWith(['\\'.TextType::class, TextType::class])] + public function testFormTypeClass(string $type, string $class): void + { + [$options] = $this->resolveUi(['form' => ['input' => ['type' => $type], 'context' => ['foo' => ['type' => $type]]]]); + + self::assertSame($class, $options['form']['input']['type']); + self::assertSame($class, $options['form']['context']['foo']['type']); + } + + #[DataProvider('provideInvalidFormTypes')] + public function testInvalidFormType(string $type, string $message): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($message); + $this->resolveUi(['form' => ['input' => ['type' => $type]]]); + } + + /** + * @return iterable + */ + public static function provideInvalidFormTypes(): iterable + { + $aliases = implode(', ', array_map(static fn (string $alias): string => \sprintf('"%s"', $alias), array_keys(self::FORM_TYPE_ALIASES))); + $unknown = static fn (string $type): string => \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is neither a form type alias (%s) nor an existing class.', $type, $aliases); + + yield 'unknown alias' => ['foo', $unknown('foo')]; + yield 'missing class' => ['App\Missing', $unknown('App\Missing')]; + yield 'not a form type' => [\stdClass::class, 'The type "stdClass" of the launch form field "form.input" of the process "test.process" must implement "Symfony\Component\Form\FormTypeInterface".']; + yield 'button alias' => ['submit', $unknown('submit')]; + yield 'button class' => [SubmitType::class, \sprintf('The type "%s" of the launch form field "form.input" of the process "test.process" is a button type, it holds no data.', SubmitType::class)]; + } + + public function testFieldConstraints(): void + { + $constraints = [['NotBlank' => null], ['Length' => ['max' => 10]]]; + [$options] = $this->resolveUi(['form' => [ + 'input' => ['options' => ['constraints' => $constraints]], + 'context' => ['foo' => ['options' => ['constraints' => $constraints]]], + ]]); + + foreach ([$options['form']['input'], $options['form']['context']['foo']] as $field) { + $fieldConstraints = $field['options']['constraints']; + self::assertIsArray($fieldConstraints); + self::assertCount(2, $fieldConstraints); + self::assertInstanceOf(NotBlank::class, $fieldConstraints[0]); + self::assertInstanceOf(Length::class, $fieldConstraints[1]); + self::assertSame(10, $fieldConstraints[1]->max); + } + } + + public function testFieldConstraintsMustBeAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "constraints" form option of the launch form field "form.context.foo" of the process "test.process" must be an array of constraints.'); + $this->resolveUi(['form' => ['context' => ['foo' => ['options' => ['constraints' => 'NotBlank']]]]]); + } + + #[TestWith(['data'])] + #[TestWith(['mapped'])] + #[TestWith(['property_path'])] + #[TestWith(['inherit_data'])] + public function testUnsupportedFieldOption(string $option): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The "%s" form option of the launch form field "form.input" of the process "test.process" is not supported, use "default" for the initial value.', $option)); + $this->resolveUi(['form' => ['input' => ['options' => [$option => 'foo']]]]); + } + + public function testContextFieldWithoutSpec(): void + { + [$options] = $this->resolveUi(['form' => ['context' => ['comment' => null]]]); + + self::assertSame(['comment' => ['type' => TextType::class, 'options' => [], 'default' => null]], $options['form']['context']); + } + + public function testContextFieldWithAnUnknownKey(): void + { + $this->expectException(UndefinedOptionsException::class); + $this->expectExceptionMessage('The option "label" does not exist.'); + $this->resolveUi(['form' => ['context' => ['comment' => ['label' => 'Comment']]]]); + } + + public function testContextFieldIsNotAnArray(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The launch form field "form.context.comment" of the process "test.process" must be an array or null, "string" given.'); + $this->resolveUi(['form' => ['context' => ['comment' => 'text']]]); + } + + #[TestWith(['my key'])] + #[TestWith([''])] + #[TestWith(['-a'])] + #[TestWith([':a'])] + #[TestWith(['a.b'])] + #[TestWith(['a[b]'])] + public function testInvalidContextKey(int|string $key): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is not a valid form field name (letters, digits, "_", "-" and ":", starting with a letter, a digit or "_").', $key)); + $this->resolveUi(['form' => ['context' => [$key => null]]]); + } + + /** + * A purely numeric key is cast to an integer by PHP, even quoted in YAML. + */ + #[TestWith([0])] + #[TestWith([123])] + public function testNumericContextKey(int $key): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%d" of "form.context" of the process "test.process" is not supported: purely numeric context keys are cast to integers by PHP.', $key)); + $this->resolveUi(['form' => ['context' => [(string) $key => null]]]); + } + + #[TestWith(['a-b:c'])] + #[TestWith(['_x'])] + #[TestWith(['1a'])] + #[TestWith(['A'])] + public function testValidContextKey(string $key): void + { + [$options] = $this->resolveUi(['form' => ['context' => [$key => null]]]); + + self::assertSame([$key], array_keys($options['form']['context'])); + } + + /** + * The keys are reserved whether the free context is allowed or not: "extra" is the child of the free context + * pairs, "execution_user" is set to the email of the connected user. + */ + #[TestWith(['extra', true])] + #[TestWith(['extra', false])] + #[TestWith(['execution_user', true])] + #[TestWith(['execution_user', false])] + public function testReservedContextKey(string $key, bool $allowExtraContext): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage(\sprintf('The context key "%s" of "form.context" of the process "test.process" is reserved.', $key)); + $this->resolveUi(['form' => ['allow_extra_context' => $allowExtraContext, 'context' => [$key => null]]]); + } + + /** + * @param array $form + */ + #[DataProvider('provideInvalidFileFields')] + public function testInvalidFileField(array $form, string $message): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($message); + $this->resolveUi(['form' => $form]); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidFileFields(): iterable + { + yield 'file in the context' => [ + ['context' => ['data' => ['type' => 'file']]], + 'The launch form field "form.context.data" of the process "test.process" is a file field: file fields are only supported for the input.', + ]; + yield 'file with a default value' => [ + ['input' => ['type' => 'file', 'default' => 'data.csv']], + 'The launch form field "form.input" of the process "test.process" is a file field, it cannot have a default value.', + ]; + yield 'multiple files' => [ + ['input' => ['type' => FileType::class, 'options' => ['multiple' => true]]], + 'The launch form field "form.input" of the process "test.process" cannot upload multiple files.', + ]; + } + + /** + * @param class-string<\Throwable> $exception + */ + #[DataProvider('provideInvalidForms')] + public function testInvalidForm(mixed $form, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + $this->resolveUi(['form' => $form]); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidForms(): iterable + { + yield 'not an array' => ['foo', InvalidOptionsException::class, 'The option "form" with value "foo" is expected to be of type "array"']; + yield 'unknown key' => [['fields' => []], UndefinedOptionsException::class, 'The option "fields" does not exist.']; + yield 'allow_extra_context not a boolean' => [ + ['allow_extra_context' => 'yes'], + InvalidOptionsException::class, + 'The option "allow_extra_context" with value "yes" is expected to be of type "bool"', + ]; + } + + public function testFormConstraints(): void + { + [$options, $deprecations] = $this->resolveUi(['form' => ['constraints' => [['NotBlank' => null]]]]); + + self::assertSame([], $deprecations); + self::assertCount(1, $options['form']['constraints']); + self::assertInstanceOf(NotBlank::class, $options['form']['constraints'][0]); + } + + public function testDeprecatedConstraintsAreEffective(): void + { + [$options, $deprecations] = $this->resolveUi(['constraints' => [['NotBlank' => null]]]); + + self::assertSame([self::deprecation('constraints', 'form.constraints')], $deprecations); + self::assertCount(1, $options['form']['constraints']); + self::assertInstanceOf(NotBlank::class, $options['form']['constraints'][0]); + } + + public function testConstraintsConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "constraints" and "form.constraints" UI options of the process "test.process" cannot be used together, use "form.constraints" only.'); + $this->resolveUi(['constraints' => [], 'form' => ['constraints' => []]]); + } + + public function testEntrypointTypeConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The "entrypoint_type" and "form.input" UI options of the process "test.process" cannot be used together, use "form.input" only.'); + $this->resolveUi(['entrypoint_type' => 'text', 'form' => ['input' => ['type' => 'text']]]); + } + + /** + * A null "form.input" is not given: no conflict with "entrypoint_type". + */ + public function testEntrypointTypeWithANullFormInput(): void + { + [$options, $deprecations] = $this->resolveUi(['entrypoint_type' => 'file', 'form' => ['input' => null, 'context' => ['delimiter' => null]]]); + + self::assertSame([self::deprecation('entrypoint_type', 'form.input.type')], $deprecations); + self::assertSame(['type' => FileType::class, 'options' => [], 'default' => null], $options['form']['input']); + self::assertSame(['delimiter'], array_keys($options['form']['context'])); + } + + /** + * The conflict depends on the presence of the options, not on their values. + */ + public function testNullInputDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); + $this->resolveUi(['form' => ['input' => ['default' => null]], 'default' => ['input' => null]]); + } + + /** + * @param array $ui + * @param array $input + */ + #[DataProvider('provideDeprecatedInputs')] + public function testDeprecatedInputIsFolded(array $ui, array $input): void + { + [$options] = $this->resolveUi($ui); + + self::assertSame($input, $options['form']['input']); + } + + /** + * @return iterable, array}> + */ + public static function provideDeprecatedInputs(): iterable + { + yield 'file entrypoint' => [['entrypoint_type' => 'file'], ['type' => FileType::class, 'options' => [], 'default' => null]]; + yield 'text entrypoint with a default input' => [ + ['entrypoint_type' => 'text', 'default' => ['input' => 'data.csv']], + ['type' => TextType::class, 'options' => [], 'default' => 'data.csv'], + ]; + yield 'file entrypoint with a default input' => [ + ['entrypoint_type' => 'file', 'default' => ['input' => 'data.csv']], + ['type' => FileType::class, 'options' => [], 'default' => null], + ]; + yield 'declared input without default' => [ + ['form' => ['input' => ['type' => 'textarea']], 'default' => ['input' => 'data.csv']], + ['type' => TextareaType::class, 'options' => [], 'default' => 'data.csv'], + ]; + yield 'declared file input without default' => [ + ['form' => ['input' => ['type' => 'file']], 'default' => ['input' => 'data.csv']], + ['type' => FileType::class, 'options' => [], 'default' => null], + ]; + } + + public function testDeprecatedContextDefaultIsFolded(): void + { + [$options] = $this->resolveUi([ + 'form' => ['context' => ['delimiter' => ['type' => 'text'], 'comment' => null]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ','], ['key' => 'delimiter', 'value' => ';']]], + ]); + + self::assertSame(';', $options['form']['context']['delimiter']['default']); + self::assertNull($options['form']['context']['comment']['default']); + } + + public function testContextDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the context key "delimiter" of the process "test.process" is set both in "default.context" and in "form.context.delimiter.default", use "form.context.delimiter.default" only.'); + $this->resolveUi([ + 'form' => ['context' => ['delimiter' => ['default' => null]]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], + ]); + } + + public function testInputDefaultConflict(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The default value of the input of the process "test.process" is set both in "default.input" and in "form.input.default", use "form.input.default" only.'); + $this->resolveUi(['form' => ['input' => ['default' => 'a.csv']], 'default' => ['input' => 'b.csv']]); + } + + public function testExtraContextKeyNotAllowed(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The context key "foo" of "default.context" of the process "test.process" is not declared in "form.context", and "form.allow_extra_context" is false.'); + $this->resolveUi([ + 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], + 'default' => ['context' => [['key' => 'foo', 'value' => 'bar']]], + ]); + } + + public function testDeclaredContextKeyWithoutExtraContext(): void + { + [$options] = $this->resolveUi([ + 'form' => ['allow_extra_context' => false, 'context' => ['delimiter' => null]], + 'default' => ['context' => [['key' => 'delimiter', 'value' => ',']]], + ]); + + self::assertFalse($options['form']['allow_extra_context']); + self::assertSame(',', $options['form']['context']['delimiter']['default']); + } + + /** + * @param array $ui + */ + #[DataProvider('provideDeprecatedOptions')] + public function testDeprecatedOption(array $ui, string $deprecation): void + { + [, $deprecations] = $this->resolveUi($ui); + + self::assertSame([$deprecation], $deprecations); + } + + /** + * @return iterable, string}> + */ + public static function provideDeprecatedOptions(): iterable + { + yield 'entrypoint_type' => [['entrypoint_type' => 'text'], self::deprecation('entrypoint_type', 'form.input.type')]; + yield 'default' => [['default' => []], self::deprecation('default', 'form.input.default" and "form.context..default')]; + yield 'constraints' => [['constraints' => []], self::deprecation('constraints', 'form.constraints')]; + } + + public function testNewFormatIsNotDeprecated(): void + { + [, $deprecations] = $this->resolveUi([ + 'ui_launch_mode' => 'form', + 'form' => [ + 'input' => ['type' => 'file', 'options' => ['constraints' => [['NotNull' => null]]]], + 'context' => ['delimiter' => ['type' => 'choice', 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'allow_extra_context' => false, + 'constraints' => [], + ], + ]); + + self::assertSame([], $deprecations); + } + + public function testLaunchFormDefaultsOfAnUnknownProcess(): void + { + $this->expectException(MissingProcessException::class); + $this->createManager(['test.process' => $this->rawProcess()])->getLaunchFormDefaults('unknown'); + } + + /** + * @param array $ui + * @param array $defaults + */ + #[DataProvider('provideLaunchFormDefaults')] + public function testLaunchFormDefaults(array $ui, array $defaults): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); + + $actual = null; + $deprecations = $this->collectDeprecations(static function () use ($manager, &$actual): void { + $actual = $manager->getLaunchFormDefaults('test.process'); + }); + + self::assertSame($defaults, $actual); + self::assertSame(self::expectedDeprecations($ui), $deprecations); + + // From the UI options already resolved, without resolving them again + [$uiOptions] = $this->resolveUi($ui); + $fromUiOptions = null; + self::assertSame([], $this->collectDeprecations(static function () use ($manager, $uiOptions, &$fromUiOptions): void { + $fromUiOptions = $manager->buildLaunchFormDefaults($uiOptions); + })); + self::assertSame($defaults, $fromUiOptions); + } + + /** + * @return iterable, array}> + */ + public static function provideLaunchFormDefaults(): iterable + { + yield 'new format' => [ + ['form' => [ + 'input' => ['default' => 'data.csv'], + 'context' => ['delimiter' => ['default' => ','], 'comment' => null, 'dry_run' => ['type' => 'checkbox', 'default' => false]], + ]], + ['input' => 'data.csv', 'context' => ['delimiter' => ',', 'comment' => null, 'dry_run' => false]], + ]; + yield 'deprecated format' => [ + ['default' => ['input' => 'data.csv', 'context' => [['key' => 'foo', 'value' => 'bar']]]], + ['input' => 'data.csv', 'context' => ['foo' => 'bar']], + ]; + yield 'mixed format' => [ + [ + 'form' => ['context' => ['delimiter' => null, 'comment' => ['default' => 'none']]], + 'default' => ['context' => [['key' => 'foo', 'value' => 'bar'], ['key' => 'delimiter', 'value' => ';']]], + ], + ['input' => null, 'context' => ['delimiter' => ';', 'comment' => 'none', 'foo' => 'bar']], + ]; + yield 'file input' => [['form' => ['input' => ['type' => 'file']]], ['input' => null, 'context' => []]]; + // The last row of a key wins, the free keys keep the order of their first row + yield 'duplicated free keys' => [ + ['default' => ['context' => [['key' => 'foo', 'value' => 'a'], ['key' => 'bar', 'value' => 'b'], ['key' => 'foo', 'value' => 'c']]]], + ['input' => null, 'context' => ['foo' => 'c', 'bar' => 'b']], + ]; + // The declared keys first, in their order of declaration, whatever the order of the deprecated rows + yield 'declared keys first' => [ + [ + 'form' => ['context' => ['b' => null, 'a' => ['default' => 'A']]], + 'default' => ['context' => [['key' => 'free', 'value' => 'F'], ['key' => 'b', 'value' => 'B']]], + ], + ['input' => null, 'context' => ['b' => 'B', 'a' => 'A', 'free' => 'F']], + ]; + } + + /** + * The duplicated rows of "default.context" are kept in the resolved "default" option (backward compatibility), + * the last one wins in the effective "form" option. + */ + public function testDuplicatedDefaultContextKeys(): void + { + $rows = [['key' => 'delimiter', 'value' => ','], ['key' => 'foo', 'value' => 'a'], ['key' => 'foo', 'value' => 'b'], ['key' => 'delimiter', 'value' => ';']]; + [$options] = $this->resolveUi(['form' => ['context' => ['delimiter' => null]], 'default' => ['context' => $rows]]); + + self::assertSame($rows, $options['default']['context']); + self::assertSame(';', $options['form']['context']['delimiter']['default']); + } + + public function testDeprecationsAreTriggeredOncePerResolution(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'text']])]); + + $deprecations = $this->collectDeprecations(static function () use ($manager): void { + $manager->getUiOptions('test.process'); + $manager->getUiOptions('test.process'); + }); + + $expected = self::deprecation('entrypoint_type', 'form.input.type'); + self::assertSame([$expected, $expected], $deprecations); + } + + /** + * Each getter resolves only its own option: the deprecations of the UI options are not triggered by the + * notification and logs getters. + */ + public function testNoDeprecationsThroughTheNotificationAndLogsOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['constraints' => []]])]); + + self::assertSame([], $this->collectDeprecations(static fn () => $manager->getNotificationOptions('test.process'))); + self::assertSame([], $this->collectDeprecations(static fn () => $manager->getLogsOptions('test.process'))); + } + + /** + * An invalid launch form does not break the notification of the executions nor the purge of the logs. + */ + public function testInvalidUiOptionsDoNotBreakTheNotificationAndLogsOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess([ + 'ui' => ['form' => ['input' => ['type' => 'App\\Missing']]], + 'notification' => ['enabled' => true], + 'logs' => ['file_max_days' => 30], + ])]); + + self::assertTrue($manager->getNotificationOptions('test.process')['enabled'] ?? null); + self::assertSame(30, $manager->getLogsOptions('test.process')['file_max_days'] ?? null); $this->expectException(InvalidOptionsException::class); $manager->getUiOptions('test.process'); } + /** + * The other options are only checked as arrays when the UI options are resolved. + */ + public function testInvalidNotificationOptionsDoNotBreakTheUiOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess([ + 'ui' => ['source' => 'ERP'], + 'notification' => ['enabled' => 'yes'], + 'logs' => ['file_max_days' => 0], + ])]); + + self::assertSame('ERP', $manager->getUiOptions('test.process')['source'] ?? null); + } + + public function testNewFormatIsNotDeprecatedThroughTheNotificationAndLogsOptions(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => [ + 'ui_launch_mode' => 'form', + 'form' => ['input' => ['type' => 'file'], 'context' => ['delimiter' => ['default' => ',']], 'constraints' => []], + ]])]); + + $notification = null; + $logs = null; + self::assertSame([], $this->collectDeprecations(static function () use ($manager, &$notification, &$logs): void { + $notification = $manager->getNotificationOptions('test.process'); + $logs = $manager->getLogsOptions('test.process'); + })); + self::assertSame(['enabled' => null, 'statuses' => null, 'channels' => null, 'recipients' => null], $notification); + self::assertSame(['database_max_days' => null, 'file_max_days' => null], $logs); + } + public function testInvalidLaunchMode(): void { $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['ui_launch_mode' => 'popup']])]); @@ -291,6 +912,75 @@ public function testInvalidLogsOptions(array $options, string $exception): void $manager->getLogsOptions('test.process'); } + /** + * @param array $ui + * + * @return array{0: UiOptions, 1: list} + */ + private function resolveUi(array $ui): array + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => $ui])]); + + $options = null; + $deprecations = $this->collectDeprecations(static function () use ($manager, &$options): void { + $options = $manager->getUiOptions('test.process'); + }); + self::assertIsArray($options); + // Every resolution checks its deprecations: exactly one per deprecated option given + self::assertSame(self::expectedDeprecations($ui), $deprecations); + + return [$options, $deprecations]; + } + + /** + * The deprecations triggered by the resolution of the given UI options, in their order of resolution. + * + * @param array $ui + * + * @return list + */ + private static function expectedDeprecations(array $ui): array + { + $replacements = [ + 'entrypoint_type' => 'form.input.type', + 'constraints' => 'form.constraints', + 'default' => 'form.input.default" and "form.context..default', + ]; + $deprecations = []; + foreach ($replacements as $option => $replacement) { + if (\array_key_exists($option, $ui)) { + $deprecations[] = self::deprecation($option, $replacement); + } + } + + return $deprecations; + } + + /** + * @return list + */ + private function collectDeprecations(\Closure $callback): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } + + private static function deprecation(string $option, string $replacement): string + { + return \sprintf('Since cleverage/ui-process-bundle 3.3: The "%s" UI option of the process "test.process" is deprecated, use "%s" instead.', $option, $replacement); + } + /** * @param array> $rawConfiguration */ diff --git a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php index 54bd187..14bb537 100644 --- a/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php +++ b/tests/Twig/Runtime/ProcessExtensionRuntimeTest.php @@ -20,6 +20,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; #[CoversClass(ProcessExtensionRuntime::class)] #[UsesClass(ProcessConfigurationsManager::class)] @@ -35,11 +37,26 @@ public function testGetUiOptions(): void $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); - $options = $runtime->getUiOptions('demo.process'); + $options = []; + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + return true; + }, \E_USER_DEPRECATED); + try { + $options = $runtime->getUiOptions('demo.process'); + } finally { + restore_error_handler(); + } + + self::assertSame(['Since cleverage/ui-process-bundle 3.3: The "entrypoint_type" UI option of the process "demo.process" is deprecated, use "form.input.type" instead.'], $deprecations); // "default" is not compared: its nested resolution depends on the symfony/options-resolver version self::assertArrayHasKey('default', $options); unset($options['default']); + // "form" is the effective launch form, the deprecated options folded into it + self::assertArrayHasKey('form', $options); + unset($options['form']); self::assertEquals([ 'source' => 'Pim', 'target' => null, @@ -50,4 +67,51 @@ public function testGetUiOptions(): void ], $options); self::assertSame([], $runtime->getUiOptions('demo.unknown')); } + + /** + * A process with the "form" option: the effective launch form is returned, without deprecation. + */ + public function testGetUiOptionsWithAForm(): void + { + $configuration = new ProcessConfiguration('demo.process', [], ['ui' => ['ui_launch_mode' => 'form', 'form' => [ + 'input' => ['type' => 'file'], + 'context' => ['delimiter' => ['type' => 'choice', 'default' => ',', 'options' => ['choices' => [',' => ',']]]], + 'allow_extra_context' => false, + ]]]); + $registry = $this->createStub(ProcessConfigurationRegistry::class); + $registry->method('hasProcessConfiguration')->willReturn(true); + $registry->method('getProcessConfiguration')->willReturn($configuration); + $runtime = new ProcessExtensionRuntime(new ProcessConfigurationsManager($registry)); + + $options = []; + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $options = $runtime->getUiOptions('demo.process'); + } finally { + restore_error_handler(); + } + + self::assertSame([], $deprecations); + if ([] === $options) { + self::fail('The UI options of a known process are expected.'); + } + self::assertSame('form', $options['ui_launch_mode']); + self::assertSame( + [ + 'input' => ['type' => FileType::class, 'options' => [], 'default' => null], + 'context' => ['delimiter' => ['type' => ChoiceType::class, 'options' => ['choices' => [',' => ',']], 'default' => ',']], + 'allow_extra_context' => false, + 'constraints' => [], + ], + $options['form'] + ); + // The deprecated options keep their default values + self::assertSame('text', $options['entrypoint_type']); + self::assertSame([], $options['constraints']); + } }