From 14c8b995e54b5672ebb777f938fd52c1ffd1f194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tonon=20Gr=C3=A9gory?= Date: Thu, 15 Oct 2026 11:08:24 +0200 Subject: [PATCH] #160 - Full control over the launch form fields (type, options, constraints, default) through the new form UI option Add the `form` UI option declaring the `input` field and each context field of the launch form (type alias or FQCN, form type options with their constraints, default value), whether free context pairs are allowed, and the constraints of the whole form. Typed values are converted into process values by the new ContextValueNormalizer. Deprecate the `entrypoint_type`, `default` and `constraints` UI options (folded into `form` until 4.0). Reserve the `execution_user` context key, validate the format of the free context keys (launch form and schedules), resolve only the requested process option in ProcessConfigurationsManager and the UI options once per launch request. Require symfony/doctrine-bridge. Add documentation, an upgrade guide and tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + UPGRADE.md | 92 +++ composer.json | 1 + docs/cookbooks/form_file_upload.md | 58 +- docs/reference/02-process_ui_options.md | 261 +++++- .../04-process_executions_and_logs.md | 5 +- docs/reference/05-scheduler.md | 2 +- docs/reference/09-notifications.md | 4 +- src/Controller/Admin/Process/LaunchAction.php | 32 +- .../Admin/ProcessScheduleCrudController.php | 2 + src/Form/ContextValueNormalizer.php | 65 ++ src/Form/Type/LaunchType.php | 111 ++- src/Form/Type/ProcessContextType.php | 46 +- src/Manager/ProcessConfigurationsManager.php | 530 +++++++++--- src/Message/ProcessExecuteMessage.php | 5 + tests/App/Form/EntityIdType.php | 43 + tests/App/config/packages.yaml | 124 +++ .../Admin/Process/LaunchActionTest.php | 74 ++ tests/Form/ContextValueNormalizerTest.php | 104 +++ tests/Form/Fixtures/Entity.php | 24 + tests/Form/Fixtures/IntBackedEnum.php | 19 + tests/Form/Fixtures/PureEnum.php | 19 + tests/Form/Fixtures/StringBackedEnum.php | 19 + tests/Form/Fixtures/StringableEntity.php | 29 + tests/Form/Type/LaunchTypeTest.php | 259 +++++- tests/Form/Type/ProcessContextTypeTest.php | 106 ++- tests/Functional/LaunchActionTest.php | 416 +++++++++- tests/Functional/ProcessScheduleCrudTest.php | 144 ++++ .../ProcessConfigurationsManagerTest.php | 756 +++++++++++++++++- .../Runtime/ProcessExtensionRuntimeTest.php | 66 +- 30 files changed, 3167 insertions(+), 250 deletions(-) create mode 100644 src/Form/ContextValueNormalizer.php create mode 100644 tests/App/Form/EntityIdType.php create mode 100644 tests/Controller/Admin/Process/LaunchActionTest.php create mode 100644 tests/Form/ContextValueNormalizerTest.php create mode 100644 tests/Form/Fixtures/Entity.php create mode 100644 tests/Form/Fixtures/IntBackedEnum.php create mode 100644 tests/Form/Fixtures/PureEnum.php create mode 100644 tests/Form/Fixtures/StringBackedEnum.php create mode 100644 tests/Form/Fixtures/StringableEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 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']); + } }