├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Bridge ├── FormFactory │ ├── ConsoleFormFactory.php │ └── ConsoleFormWithDefaultValuesAndOptionsFactory.php ├── Interaction │ ├── CollectionInteractor.php │ ├── CompoundInteractor.php │ ├── DelegatingInteractor.php │ ├── Exception │ │ ├── CanNotInteractWithForm.php │ │ ├── FormNotReadyForInteraction.php │ │ └── NoNeedToInteractWithForm.php │ ├── FieldInteractor.php │ ├── FieldWithNoInteractionInteractor.php │ ├── FormInteractor.php │ └── NonInteractiveRootInteractor.php └── Transformer │ ├── AbstractChoiceTransformer.php │ ├── AbstractTextInputBasedTransformer.php │ ├── AbstractTransformer.php │ ├── CheckboxTransformer.php │ ├── ChoiceTransformer.php │ ├── DateTimeTransformer.php │ ├── Exception │ └── CouldNotResolveTransformer.php │ ├── FormToQuestionTransformer.php │ ├── IntegerTransformer.php │ ├── NumberTransformer.php │ ├── PasswordTransformer.php │ ├── TextTransformer.php │ ├── TransformerResolver.php │ └── TypeAncestryBasedTransformerResolver.php ├── Bundle ├── RegisterHelpersPass.php ├── RegisterTransformersPass.php ├── SymfonyConsoleFormBundle.php ├── SymfonyConsoleFormExtension.php ├── helpers.yml └── services.yml ├── Console ├── Command │ ├── DynamicFormBasedCommand.php │ ├── FormBasedCommand.php │ ├── FormBasedCommandCapabilities.php │ ├── FormBasedCommandWithDefault.php │ └── InteractiveFormCommand.php ├── EventListener │ ├── HandleFormBasedCommandEventListener.php │ ├── RegisterHelpersEventListener.php │ └── SetInputDefinitionOfFormBasedCommandEventListener.php ├── Formatter │ └── Format.php ├── Helper │ ├── FormHelper.php │ ├── HelperCollection.php │ └── Question │ │ └── AlwaysReturnKeyOfChoiceQuestion.php └── Input │ ├── CachedInputDefinitionFactory.php │ ├── FormBasedInputDefinitionFactory.php │ └── InputDefinitionFactory.php └── Form ├── ConsoleFormTypeExtension.php ├── EventListener └── UseInputOptionsAsEventDataEventSubscriber.php └── FormUtil.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.5.0 4 | 5 | - Support for 'translation_domain' option for labels (#27). 6 | 7 | ## v2.2.1 8 | 9 | - Forms now accept pre-filled data (#21). 10 | - You will be asked to fill in form fields until you have submitted valid data for all of them (#21). 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Matthias Noback 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony Console Form 2 | 3 | By Matthias Noback 4 | 5 | This package contains a Symfony bundle and some tools which enable you to use Symfony Form types to define and 6 | interactively process user input from the CLI. 7 | 8 | # Installation 9 | 10 | composer require matthiasnoback/symfony-console-form 11 | 12 | Enable `Matthias\SymfonyConsoleForm\Bundle\SymfonyConsoleFormBundle` in the kernel of your Symfony application. 13 | ```php 14 | add( 49 | 'name', 50 | 'text', 51 | [ 52 | 'label' => 'Your name', 53 | 'required' => true, 54 | 'data' => 'Matthias' 55 | ] 56 | ) 57 | ... 58 | ; 59 | } 60 | 61 | public function setDefaultOptions(OptionsResolverInterface $resolver) 62 | { 63 | $resolver->setDefaults(['data_class' => 'Matthias\SymfonyConsoleForm\Tests\Data\Demo']); 64 | } 65 | 66 | public function getName() 67 | { 68 | return 'test'; 69 | } 70 | } 71 | ``` 72 | 73 | The corresponding `Demo` class looks like this: 74 | 75 | ```php 76 | setName('form:demo'); 100 | } 101 | 102 | protected function execute(InputInterface $input, OutputInterface $output) 103 | { 104 | $formHelper = $this->getHelper('form'); 105 | /** @var FormHelper $formHelper */ 106 | 107 | $formData = $formHelper->interactUsingForm(DemoType::class, $input, $output); 108 | 109 | // $formData is the valid and populated form data object/array 110 | ... 111 | } 112 | } 113 | ``` 114 | 115 | ![](doc/interaction.png) 116 | 117 | When you provide command-line options with the names of the form fields, those values will be used as default values. 118 | 119 | If you add the `--no-interaction` option when running the command, it will submit the form using the input options you provided. 120 | 121 | If the submitted data is invalid the command will fail. 122 | 123 | 124 | ## Using simpler forms with custom names 125 | 126 | ```php 127 | setName('form:demo'); 141 | $this->addOption('custom-option', null, InputOption::VALUE_OPTIONAL, 'Your custom option', 'option1') 142 | } 143 | 144 | protected function execute(InputInterface $input, OutputInterface $output) 145 | { 146 | $formHelper = $this->getHelper('form'); 147 | /** @var FormHelper $formHelper */ 148 | 149 | $formData = $formHelper->interactUsingNamedForm('custom-option', ChoiceType::class, $input, $output, [ 150 | 'label' => 'Your label', 151 | 'help' => 'Additional information to help the interaction', 152 | 'choices' => [ 153 | 'Default value label' => 'option1', 154 | 'Another value Label' => 'option2', 155 | ] 156 | ]); 157 | 158 | // $formData will be "option1" or "option2" and option "--custom-option" will be used as default value 159 | ... 160 | } 161 | } 162 | ``` 163 | 164 | ## Nested Forms 165 | 166 | If you have a complex compound form, you can define options and reference form children using square brackets: 167 | 168 | ```php 169 | addOption('user[username]', null, InputOption::VALUE_OPTIONAL) 182 | ->addOption('user[email]', null, InputOption::VALUE_OPTIONAL) 183 | ->addOption('user[address][street]', null, InputOption::VALUE_OPTIONAL) 184 | ->addOption('user[address][city]', null, InputOption::VALUE_OPTIONAL) 185 | ->addOption('acceptTerms', null, InputOption::VALUE_OPTIONAL) 186 | ; 187 | } 188 | ... 189 | } 190 | ``` 191 | 192 | # TODO 193 | 194 | - Maybe: provide a way to submit a form at once, possibly using a JSON-encoded array 195 | - Add more functional tests 196 | - Show form label of root form 197 | - Show nesting in form hierarchy using breadcrumbs 198 | - When these things have been provided, release this as a package (or multiple packages for stand-alone use) 199 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matthiasnoback/symfony-console-form", 3 | "type": "library", 4 | "description": "Use Symfony forms for Console command input", 5 | "keywords": ["Symfony", "console", "form"], 6 | "homepage": "http://github.com/matthiasnoback/symfony-console-form", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Matthias Noback", 11 | "email": "matthiasnoback@gmail.com", 12 | "homepage": "http://php-and-symfony.matthiasnoback.nl" 13 | } 14 | ], 15 | "autoload": { 16 | "psr-4": { 17 | "Matthias\\SymfonyConsoleForm\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Matthias\\SymfonyConsoleForm\\Tests\\": "test/" 23 | } 24 | }, 25 | "require": { 26 | "php": ">=8.0", 27 | "symfony/form": "~5.4 || ~6.0 || ~7.0", 28 | "symfony/console": "~5.4 || ~6.0 || ~7.0", 29 | "symfony/translation": "~5.4 || ~6.0 || ~7.0" 30 | }, 31 | "require-dev": { 32 | "beberlei/assert": "~2.1", 33 | "behat/behat": "^3.6", 34 | "symfony/finder": "~5.4 || ~6.0 || ~7.0", 35 | "symfony/framework-bundle": "~5.4 || ~6.0 || ~7.0", 36 | "symfony/validator": "~5.4 || ~6.0 || ~7.0", 37 | "symfony/yaml": "~5.4 || ~6.0 || ~7.0", 38 | "symfony/security-csrf": "~5.4 || ~6.0 || ~7.0", 39 | "phpunit/phpunit": "^9.5", 40 | "symplify/easy-coding-standard": "^9.4", 41 | "symplify/coding-standard": "^9.4", 42 | "symfony/intl": "~5.4 || ~6.0 || ~7.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Bridge/FormFactory/ConsoleFormFactory.php: -------------------------------------------------------------------------------- 1 | formFactory = $formFactory; 29 | $this->formRegistry = $formRegistry; 30 | } 31 | 32 | public function createNamed( 33 | string $name, 34 | string $formType, 35 | InputInterface $input, 36 | array $options = [] 37 | ): FormInterface { 38 | $options = $this->addDefaultOptions($options); 39 | 40 | $formBuilder = $this->formFactory->createNamedBuilder($name, $formType, null, $options); 41 | 42 | $this->createChild($formBuilder, $input, $options); 43 | 44 | return $formBuilder->getForm(); 45 | } 46 | 47 | public function create(string $formType, InputInterface $input, array $options = []): FormInterface 48 | { 49 | $options = $this->addDefaultOptions($options); 50 | 51 | $formBuilder = $this->formFactory->createBuilder($formType, null, $options); 52 | 53 | $this->createChild($formBuilder, $input, $options); 54 | 55 | return $formBuilder->getForm(); 56 | } 57 | 58 | protected function createChild( 59 | FormBuilderInterface $formBuilder, 60 | InputInterface $input, 61 | array $options, 62 | ?string $name = null 63 | ): void { 64 | if ($formBuilder->getCompound()) { 65 | /** @var FormBuilderInterface $childBuilder */ 66 | foreach ($formBuilder as $childName => $childBuilder) { 67 | $this->createChild( 68 | $childBuilder, 69 | $input, 70 | $options, 71 | $name === null ? $childName : $name . '[' . $childName . ']' 72 | ); 73 | } 74 | } else { 75 | $name = $name ?? $formBuilder->getName(); 76 | if (!$input->hasOption($name)) { 77 | return; 78 | } 79 | 80 | $providedValue = $input->getOption($name); 81 | if ($providedValue === null) { 82 | return; 83 | } 84 | 85 | $value = $providedValue; 86 | try { 87 | foreach ($formBuilder->getViewTransformers() as $viewTransformer) { 88 | $value = $viewTransformer->reverseTransform($value); 89 | } 90 | foreach ($formBuilder->getModelTransformers() as $modelTransformer) { 91 | $value = $modelTransformer->reverseTransform($value); 92 | } 93 | } catch (TransformationFailedException) { 94 | } 95 | 96 | $formBuilder->setData($value); 97 | } 98 | } 99 | 100 | private function addDefaultOptions(array $options): array 101 | { 102 | $defaultOptions = []; 103 | // hack to prevent validation error "The CSRF token is invalid." 104 | foreach ($this->formRegistry->getExtensions() as $extension) { 105 | foreach ($extension->getTypeExtensions(FormType::class) as $typeExtension) { 106 | if ($typeExtension instanceof FormTypeCsrfExtension) { 107 | $defaultOptions['csrf_protection'] = false; 108 | } 109 | } 110 | } 111 | 112 | return array_replace( 113 | $defaultOptions, 114 | $options 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Bridge/Interaction/CollectionInteractor.php: -------------------------------------------------------------------------------- 1 | formInteractor = $formInteractor; 31 | } 32 | 33 | /** 34 | * @throws CanNotInteractWithForm If the input isn't interactive 35 | * @throws FormNotReadyForInteraction If the "collection" form hasn't the option "allow_add" 36 | * 37 | * @return array 38 | */ 39 | public function interactWith( 40 | FormInterface $form, 41 | HelperSet $helperSet, 42 | InputInterface $input, 43 | OutputInterface $output 44 | ) { 45 | if (!$input->isInteractive()) { 46 | throw new CanNotInteractWithForm('This interactor only works with interactive input'); 47 | } 48 | 49 | if (!FormUtil::isTypeInAncestry($form, CollectionType::class)) { 50 | throw new CanNotInteractWithForm('Expected a "collection" form'); 51 | } 52 | 53 | if (!$form->getConfig()->getOption('allow_add') && empty($form->getData())) { 54 | throw new FormNotReadyForInteraction( 55 | 'The "collection" form should have the option "allow_add" or have existing entries' 56 | ); 57 | } 58 | 59 | $this->printHeader($form, $output); 60 | 61 | $submittedData = []; 62 | $prototype = $form->getConfig()->getAttribute('prototype'); 63 | $originalData = $prototype->getData(); 64 | 65 | $askIfEntryNeedsToBeSubmitted = function ($entryNumber) use ($helperSet, $input, $output) { 66 | return $this->askIfExistingEntryShouldBeAdded($helperSet, $input, $output, $entryNumber); 67 | }; 68 | 69 | foreach ((array) $form->getData() as $key => $entryData) { 70 | $this->printEditEntryHeader($key, $output); 71 | $prototype->setData($entryData); 72 | 73 | $submittedEntry = $this->formInteractor->interactWith($prototype, $helperSet, $input, $output); 74 | if (!$form->getConfig()->getOption('allow_delete') || $askIfEntryNeedsToBeSubmitted($key)) { 75 | $submittedData[] = $submittedEntry; 76 | } 77 | } 78 | 79 | if ($form->getConfig()->getOption('allow_add')) { 80 | // reset the prototype 81 | $prototype->setData($originalData); 82 | $key = count($submittedData) - 1; 83 | while ($this->askIfContinueToAdd($helperSet, $input, $output)) { 84 | $this->printAddEntryHeader(++$key, $output); 85 | $submittedData[] = $this->formInteractor->interactWith($prototype, $helperSet, $input, $output); 86 | } 87 | } 88 | 89 | return $submittedData; 90 | } 91 | 92 | private function askIfContinueToAdd( 93 | HelperSet $helperSet, 94 | InputInterface $input, 95 | OutputInterface $output 96 | ): string { 97 | return $this->questionHelper($helperSet)->ask( 98 | $input, 99 | $output, 100 | new ConfirmationQuestion( 101 | Format::forQuestion('Add another entry to this collection?', 'n'), 102 | false 103 | ) 104 | ); 105 | } 106 | 107 | private function questionHelper(HelperSet $helperSet): QuestionHelper 108 | { 109 | $helper = $helperSet->get('question'); 110 | 111 | if (!$helper instanceof QuestionHelper) { 112 | throw new RuntimeException('HelperSet does not contain valid QuestionHelper'); 113 | } 114 | 115 | return $helper; 116 | } 117 | 118 | private function printHeader(FormInterface $form, OutputInterface $output): void 119 | { 120 | $output->writeln( 121 | strtr( 122 | '{label}', 123 | [ 124 | '{label}' => FormUtil::label($form), 125 | ] 126 | ) 127 | ); 128 | } 129 | 130 | private function printEditEntryHeader(int $entryNumber, OutputInterface $output): void 131 | { 132 | $output->writeln( 133 | strtr( 134 | 'Edit entry {entryNumber}', 135 | [ 136 | '{entryNumber}' => $entryNumber, 137 | ] 138 | ) 139 | ); 140 | } 141 | 142 | private function printAddEntryHeader(int $entryNumber, OutputInterface $output): void 143 | { 144 | $output->writeln( 145 | strtr( 146 | 'Add entry {entryNumber}', 147 | [ 148 | '{entryNumber}' => $entryNumber, 149 | ] 150 | ) 151 | ); 152 | } 153 | 154 | private function askIfExistingEntryShouldBeAdded( 155 | HelperSet $helperSet, 156 | InputInterface $input, 157 | OutputInterface $output, 158 | int $entryNumber 159 | ): string { 160 | return $this->questionHelper($helperSet)->ask( 161 | $input, 162 | $output, 163 | new ConfirmationQuestion( 164 | Format::forQuestion( 165 | strtr( 166 | 'Add entry {entryNumber} to the submitted entries?', 167 | [ 168 | '{entryNumber}' => $entryNumber, 169 | ] 170 | ), 171 | 'y' 172 | ), 173 | true 174 | ) 175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Bridge/Interaction/CompoundInteractor.php: -------------------------------------------------------------------------------- 1 | formInteractor = $formInteractor; 26 | } 27 | 28 | /** 29 | * @throws CanNotInteractWithForm If the input isn't interactive or a compound form 30 | * 31 | * @return array 32 | */ 33 | public function interactWith( 34 | FormInterface $form, 35 | HelperSet $helperSet, 36 | InputInterface $input, 37 | OutputInterface $output 38 | ) { 39 | if (!$input->isInteractive()) { 40 | throw new CanNotInteractWithForm('This interactor only works with interactive input'); 41 | } 42 | 43 | if (!FormUtil::isCompound($form)) { 44 | throw new CanNotInteractWithForm('Expected a compound form'); 45 | } 46 | 47 | $submittedData = []; 48 | 49 | foreach ($form->all() as $name => $field) { 50 | try { 51 | $submittedData[$name] = $this->formInteractor->interactWith($field, $helperSet, $input, $output); 52 | } catch (NoNeedToInteractWithForm $exception) { 53 | continue; 54 | } 55 | } 56 | 57 | return $submittedData; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Bridge/Interaction/DelegatingInteractor.php: -------------------------------------------------------------------------------- 1 | delegates[] = $interactor; 21 | } 22 | 23 | /** 24 | * @throws CanNotInteractWithForm If no delegate was able to interact with this form 25 | * 26 | * @return mixed 27 | */ 28 | public function interactWith( 29 | FormInterface $form, 30 | HelperSet $helperSet, 31 | InputInterface $input, 32 | OutputInterface $output 33 | ) { 34 | foreach ($this->delegates as $interactor) { 35 | try { 36 | return $interactor->interactWith($form, $helperSet, $input, $output); 37 | } catch (CanNotInteractWithForm $exception) { 38 | continue; 39 | } 40 | } 41 | 42 | throw new CanNotInteractWithForm('No delegate was able to interact with this form'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Bridge/Interaction/Exception/CanNotInteractWithForm.php: -------------------------------------------------------------------------------- 1 | transformerResolver = $transformerResolver; 27 | } 28 | 29 | /** 30 | * @throws CanNotInteractWithForm The input isn't interactive 31 | * 32 | * @return mixed 33 | */ 34 | public function interactWith( 35 | FormInterface $form, 36 | HelperSet $helperSet, 37 | InputInterface $input, 38 | OutputInterface $output 39 | ) { 40 | if (!$input->isInteractive()) { 41 | throw new CanNotInteractWithForm('This interactor only works with interactive input'); 42 | } 43 | 44 | $question = $this->transformerResolver->resolve($form)->transform($form); 45 | 46 | return $this->questionHelper($helperSet)->ask($input, $output, $question); 47 | } 48 | 49 | private function questionHelper(HelperSet $helperSet): QuestionHelper 50 | { 51 | $helper = $helperSet->get('question'); 52 | 53 | if (!$helper instanceof QuestionHelper) { 54 | throw new RuntimeException('HelperSet does not contain valid QuestionHelper'); 55 | } 56 | 57 | return $helper; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Bridge/Interaction/FieldWithNoInteractionInteractor.php: -------------------------------------------------------------------------------- 1 | isRoot()) { 26 | throw new CanNotInteractWithForm('This interactor only works with root forms'); 27 | } 28 | 29 | if ($input->isInteractive()) { 30 | throw new CanNotInteractWithForm('This interactor only works with non-interactive input'); 31 | } 32 | 33 | /* 34 | * We need to adjust the input values for repeated types by copying the provided value to both of the 35 | * repeated fields. 36 | * 37 | * The fix was provided by @craigh 38 | * 39 | * P.S. If we need to add another fix like this, we should move this out to dedicated "input fixer" classes. 40 | */ 41 | $this->fixInputForField($input, $form); 42 | 43 | // use the original input as the submitted data 44 | return $input; 45 | } 46 | 47 | private function fixInputForField(InputInterface $input, FormInterface $form, ?string $name = null): void 48 | { 49 | $config = $form->getConfig(); 50 | $isRepeatedField = $config->getType()->getInnerType() instanceof RepeatedType; 51 | if (!$isRepeatedField && $config->getCompound()) { 52 | foreach ($form->all() as $childName => $field) { 53 | $subName = $name === null ? $childName : $name . '[' . $childName . ']'; 54 | $this->fixInputForField($input, $field, $subName); 55 | } 56 | } else { 57 | $name = $name ?? $form->getName(); 58 | if ($isRepeatedField && $input->hasOption($name)) { 59 | $input->setOption($name, [ 60 | $config->getOption('first_name') => $input->getOption($name), 61 | $config->getOption('second_name') => $input->getOption($name), 62 | ]); 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/AbstractChoiceTransformer.php: -------------------------------------------------------------------------------- 1 | getData(); 15 | if (is_array($defaultValue)) { 16 | $defaultValue = implode(',', $defaultValue); 17 | } 18 | 19 | return $defaultValue; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/AbstractTextInputBasedTransformer.php: -------------------------------------------------------------------------------- 1 | questionFrom($form), $this->defaultValueFrom($form)); 15 | } 16 | 17 | protected function defaultValueFrom(FormInterface $form) 18 | { 19 | return $form->getViewData(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/AbstractTransformer.php: -------------------------------------------------------------------------------- 1 | translator = $translator; 23 | } 24 | 25 | abstract protected function defaultValueFrom(FormInterface $form); 26 | 27 | protected function questionFrom(FormInterface $form): string 28 | { 29 | $translationDomain = $this->translationDomainFrom($form); 30 | $question = $this->translator->trans(FormUtil::label($form), [], $translationDomain); 31 | $help = FormUtil::help($form); 32 | if ($help !== null) { 33 | $help = $this->translator->trans($help, FormUtil::helpTranslationParameters($form), $translationDomain); 34 | } 35 | 36 | return $this->formattedQuestion($question, $this->defaultValueFrom($form), $help); 37 | } 38 | 39 | protected function translationDomainFrom(FormInterface $form): ?string 40 | { 41 | while ((null === $domain = $form->getConfig()->getOption('translation_domain')) && $form->getParent()) { 42 | $form = $form->getParent(); 43 | } 44 | 45 | return $domain; 46 | } 47 | 48 | /** 49 | * @param mixed $defaultValue 50 | * @return string 51 | */ 52 | protected function formattedQuestion(string $question, $defaultValue, ?string $help = null): string 53 | { 54 | return Format::forQuestion($question, $defaultValue, $help); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/CheckboxTransformer.php: -------------------------------------------------------------------------------- 1 | questionFrom($form), $this->defaultValueFrom($form)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/ChoiceTransformer.php: -------------------------------------------------------------------------------- 1 | createView(); 15 | 16 | $question = new AlwaysReturnKeyOfChoiceQuestion($this->questionFrom($form), $formView->vars['choices'], $this->defaultValueFrom($form)); 17 | 18 | if ($form->getConfig()->getOption('multiple')) { 19 | $question->setMultiselect(true); 20 | } 21 | 22 | return $question; 23 | } 24 | 25 | protected function defaultValueFrom(FormInterface $form) 26 | { 27 | $defaultValue = parent::defaultValueFrom($form); 28 | 29 | /* 30 | * $defaultValue can be anything, since it's the form's (default) data. For the ChoiceType form type the default 31 | * value may be derived from the choice_label option, which transforms the data to a string. We look for the 32 | * choice matching the default data and return its calculated value. 33 | */ 34 | $formView = $form->createView(); 35 | foreach ($formView->vars['choices'] as $choiceView) { 36 | /** @var ChoiceView $choiceView */ 37 | if ($choiceView->data == $defaultValue) { 38 | return $choiceView->value; 39 | } 40 | } 41 | 42 | return $defaultValue; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/DateTimeTransformer.php: -------------------------------------------------------------------------------- 1 | getViewData(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/Exception/CouldNotResolveTransformer.php: -------------------------------------------------------------------------------- 1 | setHidden(true); 14 | 15 | return $question; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Bridge/Transformer/TextTransformer.php: -------------------------------------------------------------------------------- 1 | transformers[$formType] = $transformer; 19 | } 20 | 21 | /** 22 | * @throws CouldNotResolveTransformer 23 | */ 24 | public function resolve(FormInterface $form): FormToQuestionTransformer 25 | { 26 | $types = FormUtil::typeAncestry($form); 27 | 28 | foreach ($types as $type) { 29 | if (isset($this->transformers[$type])) { 30 | return $this->transformers[$type]; 31 | } 32 | } 33 | 34 | throw new CouldNotResolveTransformer( 35 | sprintf( 36 | 'Could not find a transformer for any of these types (%s)', 37 | implode(', ', $types) 38 | ) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Bundle/RegisterHelpersPass.php: -------------------------------------------------------------------------------- 1 | helperCollectionId = $helperCollectionId; 25 | $this->tagName = $tagName; 26 | } 27 | 28 | /** 29 | * @throws LogicException 30 | */ 31 | public function process(ContainerBuilder $container): void 32 | { 33 | if (!$container->has($this->helperCollectionId)) { 34 | throw new LogicException( 35 | sprintf( 36 | 'Helper collection service "%s" is not defined', 37 | $this->helperCollectionId 38 | ) 39 | ); 40 | } 41 | 42 | $helperCollection = $container->findDefinition($this->helperCollectionId); 43 | 44 | foreach ($container->findTaggedServiceIds($this->tagName) as $helperServiceId => $tags) { 45 | $helperCollection->addMethodCall('set', [new Reference($helperServiceId)]); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Bundle/RegisterTransformersPass.php: -------------------------------------------------------------------------------- 1 | getDefinition('matthias_symfony_console.transformer_resolver'); 14 | foreach ($container->findTaggedServiceIds('form_to_question_transformer') as $serviceId => $tags) { 15 | foreach ($tags as $tagAttributes) { 16 | $formType = $tagAttributes['form_type']; 17 | $formQuestionHelper->addMethodCall('addTransformer', [$formType, new Reference($serviceId)]); 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Bundle/SymfonyConsoleFormBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new RegisterTransformersPass()); 18 | 19 | $container->addCompilerPass( 20 | new RegisterHelpersPass( 21 | 'matthias_symfony_console_form.helper_collection', 22 | 'console_helper' 23 | ) 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Bundle/SymfonyConsoleFormExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yml'); 16 | $loader->load('helpers.yml'); 17 | } 18 | 19 | public function getAlias(): string 20 | { 21 | return 'symfony_console_form'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Bundle/helpers.yml: -------------------------------------------------------------------------------- 1 | services: 2 | matthias_symfony_console_form.register_helpers_event_listener: 3 | class: Matthias\SymfonyConsoleForm\Console\EventListener\RegisterHelpersEventListener 4 | public: true 5 | arguments: 6 | - "@matthias_symfony_console_form.helper_collection" 7 | tags: 8 | - { name: kernel.event_listener, event: console.command, method: onConsoleCommand, priority: 1000 } 9 | 10 | matthias_symfony_console_form.helper_collection: 11 | class: Matthias\SymfonyConsoleForm\Console\Helper\HelperCollection 12 | public: false 13 | -------------------------------------------------------------------------------- /src/Bundle/services.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | matthias_symfony_console_form.cache_directory: "%kernel.cache_dir%/matthias_symfony_console_form" 3 | 4 | services: 5 | console_form_helper: 6 | alias: matthias_symfony_console_form.form_helper 7 | 8 | matthias_symfony_console_form.form_helper: 9 | class: Matthias\SymfonyConsoleForm\Console\Helper\FormHelper 10 | public: false 11 | arguments: 12 | - "@matthias_symfony_console_form.console_form_factory" 13 | - "@matthias_symfony_console_form.delegating_interactor" 14 | tags: 15 | - { name: console_helper } 16 | 17 | matthias_symfony_console_form.input_definition_factory: 18 | class: Matthias\SymfonyConsoleForm\Console\Input\CachedInputDefinitionFactory 19 | public: false 20 | arguments: 21 | - "@matthias_symfony_console_form.real_input_definition_factory" 22 | - "%matthias_symfony_console_form.cache_directory%" 23 | - "%kernel.debug%" 24 | 25 | matthias_symfony_console_form.real_input_definition_factory: 26 | class: Matthias\SymfonyConsoleForm\Console\Input\FormBasedInputDefinitionFactory 27 | public: false 28 | arguments: 29 | - "@form.factory" 30 | 31 | matthias_symfony_console_form.abstract_transformer: 32 | abstract: true 33 | arguments: [ "@translator" ] 34 | 35 | matthias_symfony_console_form.text_transformer: 36 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\TextTransformer 37 | parent: matthias_symfony_console_form.abstract_transformer 38 | public: false 39 | tags: 40 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\TextType } 41 | 42 | matthias_symfony_console_form.integer_transformer: 43 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\IntegerTransformer 44 | parent: matthias_symfony_console_form.abstract_transformer 45 | public: false 46 | tags: 47 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\IntegerType } 48 | 49 | matthias_symfony_console_form.number_transformer: 50 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\NumberTransformer 51 | parent: matthias_symfony_console_form.abstract_transformer 52 | public: false 53 | tags: 54 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\NumberType } 55 | 56 | matthias_symfony_console_form.date_time_transformer: 57 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\DateTimeTransformer 58 | parent: matthias_symfony_console_form.abstract_transformer 59 | public: false 60 | tags: 61 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\TimeType } 62 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\DateType } 63 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\DateTimeType } 64 | 65 | matthias_symfony_console_form.password_transformer: 66 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\PasswordTransformer 67 | parent: matthias_symfony_console_form.abstract_transformer 68 | public: false 69 | tags: 70 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\PasswordType } 71 | 72 | matthias_symfony_console_form.choice_transformer: 73 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\ChoiceTransformer 74 | parent: matthias_symfony_console_form.abstract_transformer 75 | public: false 76 | tags: 77 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\ChoiceType } 78 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\CountryType } 79 | 80 | matthias_symfony_console_form.checkbox_transformer: 81 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\CheckboxTransformer 82 | parent: matthias_symfony_console_form.abstract_transformer 83 | public: false 84 | tags: 85 | - { name: form_to_question_transformer, form_type: Symfony\Component\Form\Extension\Core\Type\CheckboxType } 86 | 87 | matthias_symfony_console_form.delegating_interactor: 88 | class: Matthias\SymfonyConsoleForm\Bridge\Interaction\DelegatingInteractor 89 | public: false 90 | calls: 91 | # more specific interactors (by form type acenstry) should be higher in this list 92 | - [addInteractor, ["@matthias_symfony_console_form.field_with_no_interaction_interactor"]] 93 | - [addInteractor, ["@matthias_symfony_console_form.non_interactive_root_interactor"]] 94 | - [addInteractor, ["@matthias_symfony_console_form.collection_interactor"]] 95 | - [addInteractor, ["@matthias_symfony_console_form.compound_interactor"]] 96 | - [addInteractor, ["@matthias_symfony_console_form.field_interactor"]] 97 | 98 | matthias_symfony_console_form.compound_interactor: 99 | class: Matthias\SymfonyConsoleForm\Bridge\Interaction\CompoundInteractor 100 | public: false 101 | arguments: 102 | - "@matthias_symfony_console_form.delegating_interactor" 103 | 104 | matthias_symfony_console_form.field_with_no_interaction_interactor: 105 | class: Matthias\SymfonyConsoleForm\Bridge\Interaction\FieldWithNoInteractionInteractor 106 | public: false 107 | 108 | matthias_symfony_console_form.non_interactive_root_interactor: 109 | class: Matthias\SymfonyConsoleForm\Bridge\Interaction\NonInteractiveRootInteractor 110 | public: false 111 | arguments: 112 | - "@matthias_symfony_console_form.delegating_interactor" 113 | 114 | matthias_symfony_console_form.collection_interactor: 115 | class: Matthias\SymfonyConsoleForm\Bridge\Interaction\CollectionInteractor 116 | public: false 117 | arguments: 118 | - "@matthias_symfony_console_form.delegating_interactor" 119 | 120 | matthias_symfony_console_form.field_interactor: 121 | class: Matthias\SymfonyConsoleForm\Bridge\Interaction\FieldInteractor 122 | public: false 123 | arguments: 124 | - "@matthias_symfony_console.transformer_resolver" 125 | 126 | matthias_symfony_console.transformer_resolver: 127 | class: Matthias\SymfonyConsoleForm\Bridge\Transformer\TypeAncestryBasedTransformerResolver 128 | public: false 129 | 130 | matthias_symfony_console_form.handle_form_based_command_event_listener: 131 | class: Matthias\SymfonyConsoleForm\Console\EventListener\HandleFormBasedCommandEventListener 132 | public: true 133 | arguments: 134 | - "@matthias_symfony_console_form.form_helper" 135 | tags: 136 | - { name: kernel.event_listener, event: console.command, method: onConsoleCommand, priority: 200 } 137 | 138 | matthias_symfony_console_form.set_input_definition_of_form_based_command_event_listener: 139 | class: Matthias\SymfonyConsoleForm\Console\EventListener\SetInputDefinitionOfFormBasedCommandEventListener 140 | public: true 141 | arguments: 142 | - "@matthias_symfony_console_form.input_definition_factory" 143 | tags: 144 | - { name: kernel.event_listener, event: console.command, method: onConsoleCommand, priority: 2000 } 145 | 146 | matthias_symfony_console_form.console_form_type_extension: 147 | class: Matthias\SymfonyConsoleForm\Form\ConsoleFormTypeExtension 148 | public: true 149 | tags: 150 | - { name: form.type_extension, alias: form, extended_type: Symfony\Component\Form\Extension\Core\Type\FormType } 151 | 152 | matthias_symfony_console_form.console_form_factory: 153 | class: Matthias\SymfonyConsoleForm\Bridge\FormFactory\ConsoleFormWithDefaultValuesAndOptionsFactory 154 | public: false 155 | arguments: 156 | - "@form.factory" 157 | - "@form.registry" 158 | -------------------------------------------------------------------------------- /src/Console/Command/DynamicFormBasedCommand.php: -------------------------------------------------------------------------------- 1 | formType = $formType; 20 | $this->commandName = $commandName; 21 | 22 | parent::__construct(); 23 | } 24 | 25 | public function formType(): string 26 | { 27 | return $this->formType; 28 | } 29 | 30 | protected function configure(): void 31 | { 32 | $this->setName('form:' . $this->commandName); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Console/Command/FormBasedCommand.php: -------------------------------------------------------------------------------- 1 | formData = $data; 20 | } 21 | 22 | /** 23 | * @return mixed 24 | */ 25 | protected function formData() 26 | { 27 | if ($this->formData === null) { 28 | if (!($this instanceof FormBasedCommand)) { 29 | throw new LogicException( 30 | 'Your command should be an instance of FormBasedCommand' 31 | ); 32 | } 33 | 34 | throw new LogicException('For some reason, no form data was set'); 35 | } 36 | 37 | return $this->formData; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/Command/FormBasedCommandWithDefault.php: -------------------------------------------------------------------------------- 1 | formQuestionHelper = $formQuestionHelper; 20 | } 21 | 22 | public function onConsoleCommand(ConsoleCommandEvent $event): void 23 | { 24 | $command = $event->getCommand(); 25 | if (!($command instanceof FormBasedCommand)) { 26 | return; 27 | } 28 | 29 | $input = $event->getInput(); 30 | $output = $event->getOutput(); 31 | $defaultData = null; 32 | if ($command instanceof FormBasedCommandWithDefault) { 33 | $defaultData = $command->getFormDefault(); 34 | } 35 | 36 | $formData = $this->formQuestionHelper->interactUsingForm( 37 | $command->formType(), 38 | $input, 39 | $output, 40 | [], 41 | $defaultData 42 | ); 43 | 44 | $command->setFormData($formData); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Console/EventListener/RegisterHelpersEventListener.php: -------------------------------------------------------------------------------- 1 | helperCollection = $helperCollection; 19 | } 20 | 21 | public function onConsoleCommand(ConsoleCommandEvent $event): void 22 | { 23 | $command = $event->getCommand(); 24 | 25 | if ($command === null) { 26 | throw new RuntimeException('Received ConsoleCommandEvent without Command instance!'); 27 | } 28 | 29 | $helperSet = $command->getHelperSet(); 30 | 31 | $this->helperCollection->addTo($helperSet); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Console/EventListener/SetInputDefinitionOfFormBasedCommandEventListener.php: -------------------------------------------------------------------------------- 1 | inputDefinitionFactory = $inputDefinitionFactory; 25 | } 26 | 27 | public function onConsoleCommand(ConsoleCommandEvent $event): void 28 | { 29 | $command = $event->getCommand(); 30 | if ($command instanceof HelpCommand) { 31 | $command = $this->getCommandFromHelpCommand($command); 32 | } 33 | 34 | if (!($command instanceof FormBasedCommand)) { 35 | return; 36 | } 37 | 38 | $inputDefinition = $this->inputDefinitionFactory->createForFormType($command->formType()); 39 | $this->setInputDefinition($command, $event->getInput(), $inputDefinition); 40 | } 41 | 42 | private function setInputDefinition(Command $command, InputInterface $input, InputDefinition $inputDefinition): void 43 | { 44 | $command->setDefinition($inputDefinition); 45 | $command->mergeApplicationDefinition(); 46 | $input->bind($command->getDefinition()); 47 | } 48 | 49 | private function getCommandFromHelpCommand(HelpCommand $helpCommand): ?Command 50 | { 51 | // hackish way of retrieving the command for which help was asked 52 | $reflectionObject = new ReflectionObject($helpCommand); 53 | $commandProperty = $reflectionObject->getProperty('command'); 54 | $commandProperty->setAccessible(true); 55 | 56 | return $commandProperty->getValue($helpCommand); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Console/Formatter/Format.php: -------------------------------------------------------------------------------- 1 | (string)$defaultValue] 15 | ) : ''; 16 | $help = $help !== null ? '' . $help . '' . "\n" : ''; 17 | return strtr( 18 | '{help}{question}{default}: ', 19 | [ 20 | '{help}' => $help, 21 | '{question}' => $question, 22 | '{default}' => (string)$default, 23 | ] 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Console/Helper/FormHelper.php: -------------------------------------------------------------------------------- 1 | formFactory = $formFactory; 26 | $this->formInteractor = $formInteractor; 27 | } 28 | 29 | public function getName(): string 30 | { 31 | return 'form'; 32 | } 33 | 34 | /** 35 | * @param mixed $data 36 | * 37 | * @return mixed 38 | */ 39 | public function interactUsingNamedForm( 40 | ?string $name, 41 | string $formType, 42 | InputInterface $input, 43 | OutputInterface $output, 44 | array $options = [], 45 | $data = null 46 | ) { 47 | $validFormFields = []; 48 | 49 | do { 50 | if ($name === null) { 51 | $form = $this->formFactory->create($formType, $input, $options); 52 | } else { 53 | $form = $this->formFactory->createNamed($name, $formType, $input, $options); 54 | } 55 | if ($data !== null) { 56 | $form->setData($data); 57 | } 58 | 59 | // if we are rerunning the form for invalid data we don't need the fields that are already valid. 60 | foreach ($validFormFields as $validFormField) { 61 | $form->remove($validFormField); 62 | } 63 | 64 | $submittedData = $this->formInteractor->interactWith($form, $this->getHelperSet(), $input, $output); 65 | 66 | $form->submit($submittedData, false); 67 | 68 | // save the current data 69 | $data = $form->getData(); 70 | 71 | if (!$form->isValid()) { 72 | $formErrors = $form->getErrors(true, false); 73 | $output->write(sprintf('Invalid data provided: %s', $formErrors)); 74 | if ($this->noErrorsCanBeFixed($formErrors)) { 75 | $violationPaths = $this->constraintViolationPaths($formErrors); 76 | $hint = (count($violationPaths) > 0 ? ' (Violations on unused fields: ' . implode(', ', $violationPaths) . ')' : ''); 77 | throw new RuntimeException( 78 | 'Errors out of the form\'s scope - do you have validation constraints on properties not used in the form?' 79 | . $hint 80 | ); 81 | } 82 | array_map( 83 | function (FormInterface $formField) use (&$validFormFields) { 84 | if ($formField->isValid()) { 85 | $validFormFields[] = $formField->getName(); 86 | } 87 | }, 88 | $form->all() 89 | ); 90 | 91 | if (!$input->isInteractive()) { 92 | throw new RuntimeException('There were form errors.'); 93 | } 94 | } 95 | } while (!$form->isValid()); 96 | 97 | return $data; 98 | } 99 | 100 | /** 101 | * @param mixed $data 102 | * 103 | * @return mixed 104 | */ 105 | public function interactUsingForm( 106 | string $formType, 107 | InputInterface $input, 108 | OutputInterface $output, 109 | array $options = [], 110 | $data = null 111 | ) { 112 | return $this->interactUsingNamedForm(null, $formType, $input, $output, $options, $data); 113 | } 114 | 115 | protected function noErrorsCanBeFixed(FormErrorIterator $errors): bool 116 | { 117 | // none of the remaining errors is related to a value of a form field 118 | return $errors->count() > 0 && 119 | count(array_filter(iterator_to_array($errors), function ($error) { 120 | return $error instanceof FormErrorIterator; 121 | })) === 0; 122 | } 123 | 124 | protected function constraintViolationPaths(FormErrorIterator $errors): array 125 | { 126 | $paths = []; 127 | foreach ($errors as $error) { 128 | if (!$error instanceof FormError) { 129 | continue; 130 | } 131 | $cause = $error->getCause(); 132 | if (!$cause instanceof ConstraintViolationInterface) { 133 | continue; 134 | } 135 | $paths[] = $cause->getPropertyPath(); 136 | } 137 | 138 | return $paths; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Console/Helper/HelperCollection.php: -------------------------------------------------------------------------------- 1 | helpers[] = $helper; 18 | } 19 | 20 | public function addTo(HelperSet $helperSet): void 21 | { 22 | foreach ($this->helpers as $helper) { 23 | $helperSet->set($helper); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Console/Helper/Question/AlwaysReturnKeyOfChoiceQuestion.php: -------------------------------------------------------------------------------- 1 | assertFlatChoiceViewsArray($choiceViews); 34 | 35 | $this->choiceViews = $choiceViews; 36 | 37 | parent::__construct($question, $this->prepareChoices(), $default); 38 | 39 | $this->setAutocompleterValues($this->prepareAutocompleteValues()); 40 | } 41 | 42 | /** 43 | * @param bool $multiselect 44 | */ 45 | public function setMultiselect(bool $multiselect): static 46 | { 47 | $this->_multiselect = $multiselect; 48 | 49 | return parent::setMultiselect($multiselect); 50 | } 51 | 52 | /** 53 | * @param string $errorMessage 54 | */ 55 | public function setErrorMessage(string $errorMessage): static 56 | { 57 | $this->_errorMessage = $errorMessage; 58 | 59 | return parent::setErrorMessage($errorMessage); 60 | } 61 | 62 | /** 63 | * @return callable|null 64 | */ 65 | public function getValidator(): ?callable 66 | { 67 | return function ($selected) { 68 | // Collapse all spaces. 69 | $selectedChoices = str_replace(' ', '', $selected); 70 | 71 | if ($this->_multiselect) { 72 | // Check for a separated comma values 73 | if (!preg_match('/^[a-zA-Z0-9_-]+(?:,[a-zA-Z0-9_-]+)*$/', $selectedChoices)) { 74 | throw new InvalidArgumentException(sprintf($this->_errorMessage, $selected)); 75 | } 76 | $selectedChoices = explode(',', $selectedChoices); 77 | } else { 78 | $selectedChoices = [$selected]; 79 | } 80 | 81 | $selectedKeys = []; 82 | 83 | foreach ($selectedChoices as $selectedValue) { 84 | $selectedKeys[] = $this->resolveChoiceViewValue($selectedValue); 85 | } 86 | 87 | if ($this->_multiselect) { 88 | return $selectedKeys; 89 | } 90 | 91 | return current($selectedKeys); 92 | }; 93 | } 94 | 95 | /** 96 | * @param string $selectedValue The selected value 97 | * 98 | * @throws InvalidArgumentException 99 | * 100 | * @return string The corresponding value of the ChoiceView 101 | */ 102 | private function resolveChoiceViewValue($selectedValue) 103 | { 104 | foreach ($this->choiceViews as $choiceView) { 105 | if (in_array($selectedValue, [$choiceView->data, $choiceView->value, $choiceView->label])) { 106 | return $choiceView->value; 107 | } 108 | } 109 | 110 | throw new InvalidArgumentException(sprintf($this->_errorMessage, $selectedValue)); 111 | } 112 | 113 | /** 114 | * @return array 115 | */ 116 | private function prepareChoices() 117 | { 118 | $choices = []; 119 | foreach ($this->choiceViews as $choiceView) { 120 | $label = $choiceView->label; 121 | $data = $choiceView->data; 122 | if ($data != $choiceView->value && $this->canBeConvertedToString($data)) { 123 | $label .= ' (' . $data . ')'; 124 | } 125 | 126 | $choices[$choiceView->value] = $label; 127 | } 128 | 129 | return $choices; 130 | } 131 | 132 | /** 133 | * @return array 134 | */ 135 | private function prepareAutocompleteValues() 136 | { 137 | $autocompleteValues = []; 138 | 139 | foreach ($this->choiceViews as $choiceView) { 140 | $autocompleteValues[] = $choiceView->value; 141 | 142 | $data = $choiceView->data; 143 | if ($this->canBeConvertedToString($data)) { 144 | $autocompleteValues[] = (string)$data; 145 | } 146 | 147 | $autocompleteValues[] = $choiceView->label; 148 | } 149 | 150 | return $autocompleteValues; 151 | } 152 | 153 | /** 154 | * @param array $choiceViews 155 | * 156 | * @throws InvalidArgumentException 157 | */ 158 | private function assertFlatChoiceViewsArray(array $choiceViews) 159 | { 160 | foreach ($choiceViews as $choiceView) { 161 | if (!$choiceView instanceof ChoiceView) { 162 | throw new InvalidArgumentException('Only a flat choice hierarchy is supported'); 163 | } 164 | } 165 | } 166 | 167 | /** 168 | * @param mixed $value 169 | * @return bool 170 | */ 171 | private function canBeConvertedToString($value) 172 | { 173 | return $value === null || is_scalar($value) || (\is_object($value) && method_exists($value, '__toString')); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Console/Input/CachedInputDefinitionFactory.php: -------------------------------------------------------------------------------- 1 | inputDefinitionFactory = $inputDefinitionFactory; 29 | $this->cacheDirectory = $cacheDirectory; 30 | $this->debug = $debug; 31 | } 32 | 33 | public function createForFormType(string $formType, array &$resources = []): InputDefinition 34 | { 35 | $cache = $this->configCacheFor($formType); 36 | 37 | if ($cache->isFresh()) { 38 | return $this->inputDefinitionFromCache($cache->getPath()); 39 | } 40 | 41 | return $this->freshInputDefinition($formType, $cache, $resources); 42 | } 43 | 44 | protected function configCacheFor(string $formType): ConfigCache 45 | { 46 | return new ConfigCache($this->cacheDirectory . '/' . $formType, $this->debug); 47 | } 48 | 49 | /** 50 | * @param string $cacheFile 51 | */ 52 | private function inputDefinitionFromCache(string $cacheFile): InputDefinition 53 | { 54 | $unserialized = unserialize(file_get_contents($cacheFile)); 55 | 56 | if (!$unserialized instanceof InputDefinition) { 57 | throw new RuntimeException('Expected to get an object of type InputDefinition from the cache'); 58 | } 59 | 60 | return $unserialized; 61 | } 62 | 63 | private function freshInputDefinition(string $formType, ConfigCache $cache, array &$resources): InputDefinition 64 | { 65 | $inputDefinition = $this->inputDefinitionFactory->createForFormType($formType, $resources); 66 | $cache->write(serialize($inputDefinition), $resources); 67 | 68 | return $inputDefinition; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Console/Input/FormBasedInputDefinitionFactory.php: -------------------------------------------------------------------------------- 1 | formFactory = $formFactory; 24 | } 25 | 26 | public function createForFormType(string $formType, array &$resources = []): InputDefinition 27 | { 28 | $resources[] = new FileResource(__FILE__); 29 | 30 | $form = $this->formFactory->create($formType); 31 | 32 | $actualFormType = $form->getConfig()->getType()->getInnerType(); 33 | $reflection = new ReflectionObject($actualFormType); 34 | $resources[] = new FileResource($reflection->getFileName()); 35 | 36 | $inputDefinition = new InputDefinition(); 37 | 38 | $this->addFormToInputDefinition($inputDefinition, $form); 39 | 40 | return $inputDefinition; 41 | } 42 | 43 | private function addFormToInputDefinition(InputDefinition $inputDefinition, FormInterface $form, ?string $name = null): void 44 | { 45 | $repeatedField = $form->getConfig()->getType()->getInnerType() instanceof RepeatedType; 46 | if (!$repeatedField && $form->getConfig()->getCompound()) { 47 | foreach ($form->all() as $childName => $field) { 48 | $subName = $name === null ? $childName : $name . '[' . $childName . ']'; 49 | $this->addFormToInputDefinition($inputDefinition, $field, $subName); 50 | } 51 | } else { 52 | $name = $name ?? $form->getName(); 53 | $type = InputOption::VALUE_REQUIRED; 54 | $default = $this->resolveDefaultValue($form); 55 | $description = FormUtil::label($form); 56 | 57 | $inputDefinition->addOption(new InputOption($name, null, $type, $description, $default)); 58 | } 59 | } 60 | 61 | private function resolveDefaultValue(FormInterface $field): string | bool | int | float | array | null 62 | { 63 | $default = $field->getConfig()->getOption('data', null); 64 | 65 | if (is_scalar($default) || is_null($default)) { 66 | return $default; 67 | } 68 | 69 | return null; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Console/Input/InputDefinitionFactory.php: -------------------------------------------------------------------------------- 1 | addEventSubscriber(new UseInputOptionsAsEventDataEventSubscriber()); 15 | } 16 | 17 | public function getExtendedType(): string 18 | { 19 | return FormType::class; 20 | } 21 | 22 | public static function getExtendedTypes(): iterable 23 | { 24 | return [FormType::class]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Form/EventListener/UseInputOptionsAsEventDataEventSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onPreSubmit', 18 | ]; 19 | } 20 | 21 | public function onPreSubmit(FormEvent $event): void 22 | { 23 | $input = $event->getData(); 24 | if (!($input instanceof InputInterface)) { 25 | return; 26 | } 27 | 28 | $event->setData($this->convertInputToSubmittedData($input, $event->getForm())); 29 | } 30 | 31 | private function convertInputToSubmittedData(InputInterface $input, FormInterface $form, ?string $name = null): mixed 32 | { 33 | $submittedData = null; 34 | if ($form->getConfig()->getCompound()) { 35 | $submittedData = []; 36 | $repeatedField = $form->getConfig()->getType()->getInnerType() instanceof RepeatedType; 37 | foreach ($form->all() as $childName => $field) { 38 | if ($repeatedField) { 39 | $submittedData = $this->convertInputToSubmittedData($input, $field, $name ?? $form->getName()); 40 | } else { 41 | $subName = $name === null ? $childName : $name . '[' . $childName . ']'; 42 | $subValue = $this->convertInputToSubmittedData($input, $field, $subName); 43 | if ($subValue !== null) { 44 | $submittedData[$childName] = $subValue; 45 | } 46 | } 47 | } 48 | if (empty($submittedData)) { 49 | $submittedData = null; 50 | } 51 | } else { 52 | $name = $name ?? $form->getName(); 53 | if ($input->hasOption($name)) { 54 | return $input->getOption($name); 55 | } 56 | } 57 | 58 | return $submittedData; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Form/FormUtil.php: -------------------------------------------------------------------------------- 1 | getConfig()->getType(), $types); 14 | 15 | return $types; 16 | } 17 | 18 | public static function typeAncestryForType(?ResolvedFormTypeInterface $formType, array &$types) 19 | { 20 | if (!($formType instanceof ResolvedFormTypeInterface)) { 21 | return; 22 | } 23 | 24 | $types[] = get_class($formType->getInnerType()); 25 | 26 | self::typeAncestryForType($formType->getParent(), $types); 27 | } 28 | 29 | public static function isTypeInAncestry(FormInterface $form, string $type): bool 30 | { 31 | return in_array($type, self::typeAncestry($form)); 32 | } 33 | 34 | public static function type(FormInterface $form): string 35 | { 36 | return get_class($form->getConfig()->getType()->getInnerType()); 37 | } 38 | 39 | public static function label(FormInterface $form): string 40 | { 41 | $label = $form->getConfig()->getOption('label'); 42 | 43 | if (!$label) { 44 | $label = self::humanize($form->getName()); 45 | } 46 | 47 | return $label; 48 | } 49 | 50 | public static function help(FormInterface $form): ?string 51 | { 52 | return $form->getConfig()->getOption('help'); 53 | } 54 | 55 | public static function helpTranslationParameters(FormInterface $form): array 56 | { 57 | return $form->getConfig()->getOption('help_translation_parameters'); 58 | } 59 | 60 | public static function isCompound(FormInterface $form): bool 61 | { 62 | return $form->getConfig()->getCompound(); 63 | } 64 | 65 | /** 66 | * Copied from Symfony\Component\Form method humanize. 67 | */ 68 | private static function humanize($text): string 69 | { 70 | return ucfirst(trim(strtolower(preg_replace(array('/([A-Z])/', '/[_\s]+/'), array('_$1', ' '), $text)))); 71 | } 72 | } 73 | --------------------------------------------------------------------------------