Skip to content

Commit

Permalink
Fix fallback prompt return value when using numeric keys in an associ…
Browse files Browse the repository at this point in the history
…ative array
  • Loading branch information
jessarcher committed Apr 11, 2024
1 parent dec31b0 commit af7ad65
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 34 deletions.
94 changes: 60 additions & 34 deletions src/Illuminate/Console/Concerns/ConfiguresPrompts.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Illuminate\Console\Concerns;

use Illuminate\Console\PromptOption;
use Illuminate\Console\PromptValidationException;
use Laravel\Prompts\ConfirmPrompt;
use Laravel\Prompts\MultiSearchPrompt;
Expand Down Expand Up @@ -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) ?? '',
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
60 changes: 60 additions & 0 deletions src/Illuminate/Console/PromptOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Illuminate\Console;

/**
* @internal
*/
class PromptOption
{
/**
* Create a new prompt option.
*
* @param string|int|null $value
* @param string $label
*/
public function __construct(public $value, public $label)
{
//
}

/**
* Return the string representation of the option.
*
* @return string
*/
public function __toString()
{
return $this->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;
}
}
123 changes: 123 additions & 0 deletions tests/Console/ConfiguresPromptsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace Illuminate\Tests\Console;

use Illuminate\Console\Application;
use Illuminate\Console\Command;
use Illuminate\Console\OutputStyle;
use Illuminate\Console\View\Components\Factory;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;

use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\select;

class ConfiguresPromptsTest extends TestCase
{
protected function tearDown(): void
{
m::close();
}

#[DataProvider('selectDataProvider')]
public function testSelectFallback($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) => $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);
}
}

0 comments on commit af7ad65

Please sign in to comment.