Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Reactive multi-select options through closure, plus auto-validation #116

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 43 additions & 16 deletions src/MultiSelectPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Laravel\Prompts;

use Closure;
use Illuminate\Support\Collection;

class MultiSelectPrompt extends Prompt
Expand All @@ -11,9 +12,9 @@ class MultiSelectPrompt extends Prompt
/**
* The options for the multi-select prompt.
*
* @var array<int|string, string>
* @var array<int|string, string>|Closure
*/
public array $options;
public array|Closure $options;

/**
* The default values the multi-select prompt.
Expand All @@ -32,12 +33,12 @@ class MultiSelectPrompt extends Prompt
/**
* Create a new MultiSelectPrompt instance.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string, string>|Collection<int|string, string>|Closure $options
* @param array<int|string>|Collection<int, int|string> $default
*/
public function __construct(
public string $label,
array|Collection $options,
array|Collection|Closure $options,
array|Collection $default = [],
public int $scroll = 5,
public bool|string $required = false,
Expand All @@ -50,11 +51,13 @@ public function __construct(

$this->initializeScrolling(0);

$options = $this->eval($this->options);

$this->on('key', fn ($key) => match ($key) {
Key::UP, Key::UP_ARROW, Key::LEFT, Key::LEFT_ARROW, Key::SHIFT_TAB, Key::CTRL_P, Key::CTRL_B, 'k', 'h' => $this->highlightPrevious(count($this->options)),
Key::DOWN, Key::DOWN_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::TAB, Key::CTRL_N, Key::CTRL_F, 'j', 'l' => $this->highlightNext(count($this->options)),
Key::UP, Key::UP_ARROW, Key::LEFT, Key::LEFT_ARROW, Key::SHIFT_TAB, Key::CTRL_P, Key::CTRL_B, 'k', 'h' => $this->highlightPrevious(count($options)),
Key::DOWN, Key::DOWN_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::TAB, Key::CTRL_N, Key::CTRL_F, 'j', 'l' => $this->highlightNext(count($options)),
Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlight(0),
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlight(count($this->options) - 1),
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlight(count($options) - 1),
Key::SPACE => $this->toggleHighlighted(),
Key::ENTER => $this->submit(),
default => null,
Expand All @@ -78,11 +81,13 @@ public function value(): array
*/
public function labels(): array
{
if (array_is_list($this->options)) {
$options = $this->eval($this->options);

if (array_is_list($options)) {
return array_map(fn ($value) => (string) $value, $this->values);
}

return array_values(array_intersect_key($this->options, array_flip($this->values)));
return array_values(array_intersect_key($options, array_flip($this->values)));
}

/**
Expand All @@ -92,19 +97,26 @@ public function labels(): array
*/
public function visible(): array
{
return array_slice($this->options, $this->firstVisible, $this->scroll, preserve_keys: true);
return array_slice(
$this->eval($this->options),
$this->firstVisible,
$this->scroll,
preserve_keys: true,
);
}

/**
* Check whether the value is currently highlighted.
*/
public function isHighlighted(string $value): bool
{
if (array_is_list($this->options)) {
return $this->options[$this->highlighted] === $value;
$options = $this->eval($this->options);

if (array_is_list($options)) {
return $options[$this->highlighted] === $value;
}

return array_keys($this->options)[$this->highlighted] === $value;
return array_keys($options)[$this->highlighted] === $value;
}

/**
Expand All @@ -120,14 +132,29 @@ public function isSelected(string $value): bool
*/
protected function toggleHighlighted(): void
{
$value = array_is_list($this->options)
? $this->options[$this->highlighted]
: array_keys($this->options)[$this->highlighted];
$options = $this->eval($this->options);

$value = array_is_list($options)
? $options[$this->highlighted]
: array_keys($options)[$this->highlighted];

if (in_array($value, $this->values)) {
$this->values = array_filter($this->values, fn ($v) => $v !== $value);
} else {
$this->values[] = $value;
}

$this->validate($this->value());
}

/**
* Returns options as an array; and re-evaluates them if they were in a closure
*
* @param array<int|string, string>|Collection<int|string, string>|Closure $options
* @return array<int|string, string>
*/
public function eval(array|Collection|Closure $options): array
{
return ($options = value($options)) instanceof Collection ? $options->toArray() : (array) $options;
}
}
2 changes: 1 addition & 1 deletion src/Prompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ private function handleKeyPress(string $key): bool
/**
* Validate the input.
*/
private function validate(mixed $value): void
protected function validate(mixed $value): void
{
$this->validated = true;

Expand Down
22 changes: 13 additions & 9 deletions src/Themes/Default/MultiSelectPromptRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class MultiSelectPromptRenderer extends Renderer implements Scrolling
*/
public function __invoke(MultiSelectPrompt $prompt): string
{
$options = $prompt->eval($prompt->options);

return match ($prompt->state) {
'submit' => $this
->box(
Expand All @@ -35,15 +37,15 @@ public function __invoke(MultiSelectPrompt $prompt): string
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'yellow',
info: count($prompt->options) > $prompt->scroll ? (count($prompt->value()).' selected') : '',
info: count($options) > $prompt->scroll ? (count($prompt->value()) . ' selected') : '',
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),

default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->renderOptions($prompt),
info: count($prompt->options) > $prompt->scroll ? (count($prompt->value()).' selected') : '',
info: count($options) > $prompt->scroll ? (count($prompt->value()) . ' selected') : '',
)
->when(
$prompt->hint,
Expand All @@ -58,16 +60,18 @@ public function __invoke(MultiSelectPrompt $prompt): string
*/
protected function renderOptions(MultiSelectPrompt $prompt): string
{
$options = $prompt->eval($prompt->options);

return $this->scrollbar(
collect($prompt->visible())
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 12))
->map(function ($label, $key) use ($prompt) {
$index = array_search($key, array_keys($prompt->options));
->map(function ($label, $key) use ($prompt, $options) {
$index = array_search($key, array_keys($options));
$active = $index === $prompt->highlighted;
if (array_is_list($prompt->options)) {
$value = $prompt->options[$index];
if (array_is_list($options)) {
$value = $options[$index];
} else {
$value = array_keys($prompt->options)[$index];
$value = array_keys($options)[$index];
}
$selected = in_array($value, $prompt->value());

Expand All @@ -90,8 +94,8 @@ protected function renderOptions(MultiSelectPrompt $prompt): string
->values(),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->options),
min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6),
count($options),
min($this->longest($options, padding: 6), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
)->implode(PHP_EOL);
}
Expand Down
4 changes: 2 additions & 2 deletions src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ function select(string $label, array|Collection $options, int|string|null $defau
/**
* Prompt the user to select multiple options.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string, string>|Collection<int|string, string>|Closure $options
* @param array<int|string>|Collection<int, int|string> $default
* @return array<int|string>
*/
function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array
function multiselect(string $label, array|Collection|Closure $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array
{
return (new MultiSelectPrompt(...func_get_args()))->prompt();
}
Expand Down
16 changes: 16 additions & 0 deletions tests/Feature/MultiSelectPromptTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,19 @@

Prompt::validateUsing(fn () => null);
});

it('supports options as closure', function () {
Prompt::fake([Key::DOWN, Key::SPACE, Key::ENTER]);

function presumablyChangingOptions()
{
return ['something', 'another'];
}

$result = multiselect(
label: 'So what are you talking about?',
options: fn () => presumablyChangingOptions(),
);

expect($result)->toBe(['another']);
});
Loading