Skip to content

Commit f29e0a5

Browse files
authored
Fix fallback prompt return value when using numeric keys in an associative array (#50995)
1 parent 321da34 commit f29e0a5

File tree

3 files changed

+243
-34
lines changed

3 files changed

+243
-34
lines changed

src/Illuminate/Console/Concerns/ConfiguresPrompts.php

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Console\Concerns;
44

5+
use Illuminate\Console\PromptOption;
56
use Illuminate\Console\PromptValidationException;
67
use Laravel\Prompts\ConfirmPrompt;
78
use Laravel\Prompts\MultiSearchPrompt;
@@ -52,28 +53,16 @@ protected function configurePrompts(InputInterface $input)
5253
));
5354

5455
SelectPrompt::fallbackUsing(fn (SelectPrompt $prompt) => $this->promptUntilValid(
55-
fn () => $this->components->choice($prompt->label, $prompt->options, $prompt->default),
56+
fn () => $this->selectFallback($prompt->label, $prompt->options, $prompt->default),
5657
false,
5758
$prompt->validate
5859
));
5960

60-
MultiSelectPrompt::fallbackUsing(function (MultiSelectPrompt $prompt) {
61-
if ($prompt->default !== []) {
62-
return $this->promptUntilValid(
63-
fn () => $this->components->choice($prompt->label, $prompt->options, implode(',', $prompt->default), multiple: true),
64-
$prompt->required,
65-
$prompt->validate
66-
);
67-
}
68-
69-
return $this->promptUntilValid(
70-
fn () => collect($this->components->choice($prompt->label, ['' => 'None', ...$prompt->options], 'None', multiple: true))
71-
->reject('')
72-
->all(),
73-
$prompt->required,
74-
$prompt->validate
75-
);
76-
});
61+
MultiSelectPrompt::fallbackUsing(fn (MultiSelectPrompt $prompt) => $this->promptUntilValid(
62+
fn () => $this->multiselectFallback($prompt->label, $prompt->options, $prompt->default, $prompt->required),
63+
$prompt->required,
64+
$prompt->validate
65+
));
7766

7867
SuggestPrompt::fallbackUsing(fn (SuggestPrompt $prompt) => $this->promptUntilValid(
7968
fn () => $this->components->askWithCompletion($prompt->label, $prompt->options, $prompt->default ?: null) ?? '',
@@ -87,7 +76,7 @@ function () use ($prompt) {
8776

8877
$options = ($prompt->options)($query);
8978

90-
return $this->components->choice($prompt->label, $options);
79+
return $this->selectFallback($prompt->label, $options);
9180
},
9281
false,
9382
$prompt->validate
@@ -99,21 +88,7 @@ function () use ($prompt) {
9988

10089
$options = ($prompt->options)($query);
10190

102-
if ($prompt->required === false) {
103-
if (array_is_list($options)) {
104-
return collect($this->components->choice($prompt->label, ['None', ...$options], 'None', multiple: true))
105-
->reject('None')
106-
->values()
107-
->all();
108-
}
109-
110-
return collect($this->components->choice($prompt->label, ['' => 'None', ...$options], '', multiple: true))
111-
->reject('')
112-
->values()
113-
->all();
114-
}
115-
116-
return $this->components->choice($prompt->label, $options, multiple: true);
91+
return $this->multiselectFallback($prompt->label, $options, required: $prompt->required);
11792
},
11893
$prompt->required,
11994
$prompt->validate
@@ -238,4 +213,55 @@ protected function restorePrompts()
238213
{
239214
Prompt::setOutput($this->output);
240215
}
216+
217+
/**
218+
* Select fallback.
219+
*
220+
* @param string $label
221+
* @param array $options
222+
* @param string|int|null $default
223+
* @return string|int
224+
*/
225+
private function selectFallback($label, $options, $default = null)
226+
{
227+
if ($default !== null) {
228+
$default = array_search($default, array_is_list($options) ? $options : array_keys($options));
229+
}
230+
231+
return PromptOption::unwrap($this->components->choice($label, PromptOption::wrap($options), $default));
232+
}
233+
234+
/**
235+
* Multi-select fallback.
236+
*
237+
* @param string $label
238+
* @param array $options
239+
* @param array $default
240+
* @param bool|string $required
241+
* @return array
242+
*/
243+
private function multiselectFallback($label, $options, $default = [], $required = false)
244+
{
245+
$options = PromptOption::wrap($options);
246+
247+
if ($required === false) {
248+
$options = [new PromptOption(null, 'None'), ...$options];
249+
250+
if ($default === []) {
251+
$default = [null];
252+
}
253+
}
254+
255+
$default = $default !== []
256+
? implode(',', array_keys(array_filter($options, fn ($option) => in_array($option->value, $default))))
257+
: null;
258+
259+
$answers = PromptOption::unwrap($this->components->choice($label, $options, $default, multiple: true));
260+
261+
if ($required === false) {
262+
return array_values(array_filter($answers, fn ($value) => $value !== null));
263+
}
264+
265+
return $answers;
266+
}
241267
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace Illuminate\Console;
4+
5+
/**
6+
* @internal
7+
*/
8+
class PromptOption
9+
{
10+
/**
11+
* Create a new prompt option.
12+
*
13+
* @param string|int|null $value
14+
* @param string $label
15+
*/
16+
public function __construct(public $value, public $label)
17+
{
18+
//
19+
}
20+
21+
/**
22+
* Return the string representation of the option.
23+
*
24+
* @return string
25+
*/
26+
public function __toString()
27+
{
28+
return $this->label;
29+
}
30+
31+
/**
32+
* Wrap the given options in PromptOption objects.
33+
*
34+
* @param array $options
35+
* @return array
36+
*/
37+
public static function wrap($options)
38+
{
39+
return array_map(
40+
fn ($label, $value) => new static(array_is_list($options) ? $label : $value, $label),
41+
$options,
42+
array_keys($options)
43+
);
44+
}
45+
46+
/**
47+
* Unwrap the given option(s).
48+
*
49+
* @param static|string|int|array $option
50+
* @return string|int|array
51+
*/
52+
public static function unwrap($option)
53+
{
54+
if (is_array($option)) {
55+
return array_map(static::unwrap(...), $option);
56+
}
57+
58+
return $option instanceof static ? $option->value : $option;
59+
}
60+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Console;
4+
5+
use Illuminate\Console\Application;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Console\OutputStyle;
8+
use Illuminate\Console\View\Components\Factory;
9+
use Mockery as m;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use PHPUnit\Framework\TestCase;
12+
use Symfony\Component\Console\Input\ArrayInput;
13+
use Symfony\Component\Console\Output\NullOutput;
14+
15+
use function Laravel\Prompts\multiselect;
16+
use function Laravel\Prompts\select;
17+
18+
class ConfiguresPromptsTest extends TestCase
19+
{
20+
protected function tearDown(): void
21+
{
22+
m::close();
23+
}
24+
25+
#[DataProvider('selectDataProvider')]
26+
public function testSelectFallback($prompt, $expectedDefault, $selection, $expectedReturn)
27+
{
28+
$command = new class($prompt) extends Command
29+
{
30+
public $answer;
31+
32+
public function __construct(protected $prompt)
33+
{
34+
parent::__construct();
35+
}
36+
37+
public function handle()
38+
{
39+
$this->answer = ($this->prompt)();
40+
}
41+
};
42+
43+
$this->runCommand($command, fn ($components) => $components
44+
->expects('choice')
45+
->withArgs(fn ($question, $options, $default) => $default === $expectedDefault)
46+
->andReturnUsing(fn ($question, $options, $default) => $options[$selection])
47+
);
48+
49+
$this->assertSame($expectedReturn, $command->answer);
50+
}
51+
52+
public static function selectDataProvider()
53+
{
54+
return [
55+
'list with no default' => [fn () => select('foo', ['a', 'b', 'c']), null, 1, 'b'],
56+
'numeric keys with no default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c']), null, 1, 2],
57+
'assoc with no default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), null, 1, 'b'],
58+
'list with default' => [fn () => select('foo', ['a', 'b', 'c'], 'b'), 1, 1, 'b'],
59+
'numeric keys with default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c'], 2), 1, 1, 2],
60+
'assoc with default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b'), 1, 1, 'b'],
61+
];
62+
}
63+
64+
#[DataProvider('multiselectDataProvider')]
65+
public function testMultiselectFallback($prompt, $expectedDefault, $selection, $expectedReturn)
66+
{
67+
$command = new class($prompt) extends Command
68+
{
69+
public $answer;
70+
71+
public function __construct(protected $prompt)
72+
{
73+
parent::__construct();
74+
}
75+
76+
public function handle()
77+
{
78+
$this->answer = ($this->prompt)();
79+
}
80+
};
81+
82+
$this->runCommand($command, fn ($components) => $components
83+
->expects('choice')
84+
->withArgs(fn ($question, $options, $default, $multiple) => $default === $expectedDefault && $multiple === true)
85+
->andReturnUsing(fn ($question, $options, $default, $multiple) => array_values(array_filter($options, fn ($index) => in_array($index, $selection), ARRAY_FILTER_USE_KEY)))
86+
);
87+
88+
$this->assertSame($expectedReturn, $command->answer);
89+
}
90+
91+
public static function multiselectDataProvider()
92+
{
93+
return [
94+
'list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c']), '0', [2, 3], ['b', 'c']],
95+
'numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c']), '0', [2, 3], [2, 3]],
96+
'assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), '0', [2, 3], ['b', 'c']],
97+
'list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']],
98+
'numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3]), '2,3', [2, 3], [2, 3]],
99+
'assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']],
100+
'required list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c'], required: true), null, [1, 2], ['b', 'c']],
101+
'required numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], required: true), null, [1, 2], [2, 3]],
102+
'required assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], required: true), null, [1, 2], ['b', 'c']],
103+
'required list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']],
104+
'required numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3], required: true), '1,2', [1, 2], [2, 3]],
105+
'required assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']],
106+
];
107+
}
108+
109+
protected function runCommand($command, $expectations)
110+
{
111+
$command->setLaravel($application = m::mock(Application::class));
112+
113+
$application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === OutputStyle::class)->andReturn($outputStyle = m::mock(OutputStyle::class));
114+
$application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === Factory::class)->andReturn($factory = m::mock(Factory::class));
115+
$application->shouldReceive('runningUnitTests')->andReturn(true);
116+
$application->shouldReceive('call')->with([$command, 'handle'])->andReturnUsing(fn ($callback) => call_user_func($callback));
117+
$outputStyle->shouldReceive('newLinesWritten')->andReturn(1);
118+
119+
$expectations($factory);
120+
121+
$command->run(new ArrayInput([]), new NullOutput);
122+
}
123+
}

0 commit comments

Comments
 (0)