From af7ad651fb3da91207ab787be7c1795054fded8e Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Wed, 10 Apr 2024 15:55:47 +1000 Subject: [PATCH] Fix fallback prompt return value when using numeric keys in an associative array --- .../Console/Concerns/ConfiguresPrompts.php | 94 ++++++++----- src/Illuminate/Console/PromptOption.php | 60 +++++++++ tests/Console/ConfiguresPromptsTest.php | 123 ++++++++++++++++++ 3 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 src/Illuminate/Console/PromptOption.php create mode 100644 tests/Console/ConfiguresPromptsTest.php diff --git a/src/Illuminate/Console/Concerns/ConfiguresPrompts.php b/src/Illuminate/Console/Concerns/ConfiguresPrompts.php index 8ebd3faf7207..2f6a540894fc 100644 --- a/src/Illuminate/Console/Concerns/ConfiguresPrompts.php +++ b/src/Illuminate/Console/Concerns/ConfiguresPrompts.php @@ -2,6 +2,7 @@ namespace Illuminate\Console\Concerns; +use Illuminate\Console\PromptOption; use Illuminate\Console\PromptValidationException; use Laravel\Prompts\ConfirmPrompt; use Laravel\Prompts\MultiSearchPrompt; @@ -52,28 +53,16 @@ protected function configurePrompts(InputInterface $input) )); SelectPrompt::fallbackUsing(fn (SelectPrompt $prompt) => $this->promptUntilValid( - fn () => $this->components->choice($prompt->label, $prompt->options, $prompt->default), + fn () => $this->selectFallback($prompt->label, $prompt->options, $prompt->default), false, $prompt->validate )); - MultiSelectPrompt::fallbackUsing(function (MultiSelectPrompt $prompt) { - if ($prompt->default !== []) { - return $this->promptUntilValid( - fn () => $this->components->choice($prompt->label, $prompt->options, implode(',', $prompt->default), multiple: true), - $prompt->required, - $prompt->validate - ); - } - - return $this->promptUntilValid( - fn () => collect($this->components->choice($prompt->label, ['' => 'None', ...$prompt->options], 'None', multiple: true)) - ->reject('') - ->all(), - $prompt->required, - $prompt->validate - ); - }); + MultiSelectPrompt::fallbackUsing(fn (MultiSelectPrompt $prompt) => $this->promptUntilValid( + fn () => $this->multiselectFallback($prompt->label, $prompt->options, $prompt->default, $prompt->required), + $prompt->required, + $prompt->validate + )); SuggestPrompt::fallbackUsing(fn (SuggestPrompt $prompt) => $this->promptUntilValid( fn () => $this->components->askWithCompletion($prompt->label, $prompt->options, $prompt->default ?: null) ?? '', @@ -87,7 +76,7 @@ function () use ($prompt) { $options = ($prompt->options)($query); - return $this->components->choice($prompt->label, $options); + return $this->selectFallback($prompt->label, $options); }, false, $prompt->validate @@ -99,21 +88,7 @@ function () use ($prompt) { $options = ($prompt->options)($query); - if ($prompt->required === false) { - if (array_is_list($options)) { - return collect($this->components->choice($prompt->label, ['None', ...$options], 'None', multiple: true)) - ->reject('None') - ->values() - ->all(); - } - - return collect($this->components->choice($prompt->label, ['' => 'None', ...$options], '', multiple: true)) - ->reject('') - ->values() - ->all(); - } - - return $this->components->choice($prompt->label, $options, multiple: true); + return $this->multiselectFallback($prompt->label, $options, required: $prompt->required); }, $prompt->required, $prompt->validate @@ -238,4 +213,55 @@ protected function restorePrompts() { Prompt::setOutput($this->output); } + + /** + * Select fallback. + * + * @param string $label + * @param array $options + * @param string|int|null $default + * @return string|int + */ + private function selectFallback($label, $options, $default = null) + { + if ($default !== null) { + $default = array_search($default, array_is_list($options) ? $options : array_keys($options)); + } + + return PromptOption::unwrap($this->components->choice($label, PromptOption::wrap($options), $default)); + } + + /** + * Multi-select fallback. + * + * @param string $label + * @param array $options + * @param array $default + * @param bool|string $required + * @return array + */ + private function multiselectFallback($label, $options, $default = [], $required = false) + { + $options = PromptOption::wrap($options); + + if ($required === false) { + $options = [new PromptOption(null, 'None'), ...$options]; + + if ($default === []) { + $default = [null]; + } + } + + $default = $default !== [] + ? implode(',', array_keys(array_filter($options, fn ($option) => in_array($option->value, $default)))) + : null; + + $answers = PromptOption::unwrap($this->components->choice($label, $options, $default, multiple: true)); + + if ($required === false) { + return array_values(array_filter($answers, fn ($value) => $value !== null)); + } + + return $answers; + } } diff --git a/src/Illuminate/Console/PromptOption.php b/src/Illuminate/Console/PromptOption.php new file mode 100644 index 000000000000..bef4541db229 --- /dev/null +++ b/src/Illuminate/Console/PromptOption.php @@ -0,0 +1,60 @@ +label; + } + + /** + * Wrap the given options in PromptOption objects. + * + * @param array $options + * @return array + */ + public static function wrap($options) + { + return array_map( + fn ($label, $value) => new static(array_is_list($options) ? $label : $value, $label), + $options, + array_keys($options) + ); + } + + /** + * Unwrap the given option(s). + * + * @param static|string|int|array $option + * @return string|int|array + */ + public static function unwrap($option) + { + if (is_array($option)) { + return array_map(static::unwrap(...), $option); + } + + return $option instanceof static ? $option->value : $option; + } +} diff --git a/tests/Console/ConfiguresPromptsTest.php b/tests/Console/ConfiguresPromptsTest.php new file mode 100644 index 000000000000..84f73aaae245 --- /dev/null +++ b/tests/Console/ConfiguresPromptsTest.php @@ -0,0 +1,123 @@ +answer = ($this->prompt)(); + } + }; + + $this->runCommand($command, fn ($components) => $components + ->expects('choice') + ->withArgs(fn ($question, $options, $default) => $default === $expectedDefault) + ->andReturnUsing(fn ($question, $options, $default) => $options[$selection]) + ); + + $this->assertSame($expectedReturn, $command->answer); + } + + public static function selectDataProvider() + { + return [ + 'list with no default' => [fn () => select('foo', ['a', 'b', 'c']), null, 1, 'b'], + 'numeric keys with no default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c']), null, 1, 2], + 'assoc with no default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), null, 1, 'b'], + 'list with default' => [fn () => select('foo', ['a', 'b', 'c'], 'b'), 1, 1, 'b'], + 'numeric keys with default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c'], 2), 1, 1, 2], + 'assoc with default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b'), 1, 1, 'b'], + ]; + } + + #[DataProvider('multiselectDataProvider')] + public function testMultiselectFallback($prompt, $expectedDefault, $selection, $expectedReturn) + { + $command = new class($prompt) extends Command + { + public $answer; + + public function __construct(protected $prompt) + { + parent::__construct(); + } + + public function handle() + { + $this->answer = ($this->prompt)(); + } + }; + + $this->runCommand($command, fn ($components) => $components + ->expects('choice') + ->withArgs(fn ($question, $options, $default, $multiple) => $default === $expectedDefault && $multiple === true) + ->andReturnUsing(fn ($question, $options, $default, $multiple) => array_values(array_filter($options, fn ($index) => in_array($index, $selection), ARRAY_FILTER_USE_KEY))) + ); + + $this->assertSame($expectedReturn, $command->answer); + } + + public static function multiselectDataProvider() + { + return [ + 'list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c']), '0', [2, 3], ['b', 'c']], + 'numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c']), '0', [2, 3], [2, 3]], + 'assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), '0', [2, 3], ['b', 'c']], + 'list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']], + 'numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3]), '2,3', [2, 3], [2, 3]], + 'assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']], + 'required list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c'], required: true), null, [1, 2], ['b', 'c']], + 'required numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], required: true), null, [1, 2], [2, 3]], + 'required assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], required: true), null, [1, 2], ['b', 'c']], + 'required list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']], + 'required numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3], required: true), '1,2', [1, 2], [2, 3]], + 'required assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']], + ]; + } + + protected function runCommand($command, $expectations) + { + $command->setLaravel($application = m::mock(Application::class)); + + $application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === OutputStyle::class)->andReturn($outputStyle = m::mock(OutputStyle::class)); + $application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === Factory::class)->andReturn($factory = m::mock(Factory::class)); + $application->shouldReceive('runningUnitTests')->andReturn(true); + $application->shouldReceive('call')->with([$command, 'handle'])->andReturnUsing(fn ($callback) => call_user_func($callback)); + $outputStyle->shouldReceive('newLinesWritten')->andReturn(1); + + $expectations($factory); + + $command->run(new ArrayInput([]), new NullOutput); + } +}