Skip to content

Commit

Permalink
[Enhancement] Reactive options as closure for multi-select prompt
Browse files Browse the repository at this point in the history
- Accepted options argument as closure
  - Turned options property to mixed type as it might hold the closure
  - Passed values property to the closure for reactivity; just like validate method
- Added an evaluatedOptions property for caching until it's set to null
  - Created options method that evaluates, caches and returns the options all around
    - Threw an exception when all options are no longer available
    - Removed previously selected options from values when they're gone
    - Adjusted the indexing
- Introduced a new "toggle" state to re-render without errors
  - Set the evaluatedOptions to null (resetting cache and evaluation) for toggle and error states
- Tested for closure, reactivity, and the empty options exception
  - Ensured all tests are passing
  • Loading branch information
GoodM4ven committed Mar 10, 2024
1 parent 73abd37 commit aaa2692
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 26 deletions.
103 changes: 87 additions & 16 deletions src/MultiSelectPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Laravel\Prompts;

use Closure;
use Illuminate\Support\Collection;
use Laravel\Prompts\Exceptions\NonInteractiveValidationException;

class MultiSelectPrompt extends Prompt
{
Expand All @@ -11,9 +13,16 @@ class MultiSelectPrompt extends Prompt
/**
* The options for the multi-select prompt.
*
* @var array<int|string, string>
* @var array<int|string, string>|Closure(array<int|string, string>): array<int|string, string>|Collection<int|string, string>
*/
public array $options;
public mixed $options;

/**
* The evaluated options cache.
*
* @var array<int|string, string>|null
*/
public ?array $evaluatedOptions = null;

/**
* The default values the multi-select prompt.
Expand All @@ -32,12 +41,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(array<int|string, string>): array<int|string, string>|Collection<int|string, string> $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 @@ -51,16 +60,42 @@ public function __construct(
$this->initializeScrolling(0);

$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($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::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($this->options()) - 1),
Key::SPACE => $this->toggleHighlighted(),
Key::ENTER => $this->submit(),
default => null,
});
}

/**
* Get the evaluated options, updating its cache when it's null.
*
* @return array<int|string, string>
*/
public function options(): array
{
if ($this->evaluatedOptions !== null) {
return $this->evaluatedOptions;
}

$this->evaluatedOptions = match (true) {
is_callable($this->options) => ($ops = ($this->options)($this->value())) instanceof Collection ? $ops->all() : $ops,
default => $this->options,
};

if (empty($this->evaluatedOptions)) {
throw new NonInteractiveValidationException('All options are no longer available!');
}

$this->removeUnavailableValues();
$this->adjustHighlightedOption();

return $this->evaluatedOptions;
}

/**
* Get the selected values.
*
Expand All @@ -71,18 +106,38 @@ public function value(): array
return array_values($this->values);
}

/**
* Remove values for unavailable options, after re-evaluation.
*/
protected function removeUnavailableValues(): void
{
$hasLabels = array_keys($this->evaluatedOptions) !== range(0, count($this->evaluatedOptions) - 1);

foreach ($this->values as $key => $value) {
if ($hasLabels) {
if (!array_key_exists($value, $this->evaluatedOptions)) {
unset($this->values[$key]);
}
} else {
if (!in_array($value, $this->evaluatedOptions)) {
unset($this->values[$key]);
}
}
}
}

/**
* Get the selected labels.
*
* @return array<string>
*/
public function labels(): array
{
if (array_is_list($this->options)) {
if (array_is_list($this->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($this->options(), array_flip($this->values)));
}

/**
Expand All @@ -92,19 +147,19 @@ public function labels(): array
*/
public function visible(): array
{
return array_slice($this->options, $this->firstVisible, $this->scroll, preserve_keys: true);
return array_slice($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;
if (array_is_list($this->options())) {
return $this->options()[$this->highlighted] === $value;
}

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

/**
Expand All @@ -120,14 +175,30 @@ 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];
$value = array_is_list($this->options())
? $this->options()[$this->highlighted]
: array_keys($this->options())[$this->highlighted];

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

$this->state = 'toggle';
}

/**
* Adjust the highlighted entry, after re-evaluation.
*/
protected function adjustHighlightedOption(): void
{
$totalOptions = count($this->evaluatedOptions);

if ($this->highlighted !== null && $this->highlighted >= $totalOptions) {
$this->highlighted = $totalOptions - 1;
}

$this->scrollToHighlighted($totalOptions);
}
}
20 changes: 12 additions & 8 deletions src/Themes/Default/MultiSelectPromptRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class MultiSelectPromptRenderer extends Renderer implements Scrolling
*/
public function __invoke(MultiSelectPrompt $prompt): string
{
if ($prompt->state === 'toggle' || $prompt->state === 'error') {
$prompt->evaluatedOptions = null;
}

return match ($prompt->state) {
'submit' => $this
->box(
Expand All @@ -35,15 +39,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($prompt->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($prompt->options()) > $prompt->scroll ? (count($prompt->value()).' selected') : '',
)
->when(
$prompt->hint,
Expand All @@ -62,12 +66,12 @@ protected function renderOptions(MultiSelectPrompt $prompt): string
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));
$index = array_search($key, array_keys($prompt->options()));
$active = $index === $prompt->highlighted;
if (array_is_list($prompt->options)) {
$value = $prompt->options[$index];
if (array_is_list($prompt->options())) {
$value = $prompt->options()[$index];
} else {
$value = array_keys($prompt->options)[$index];
$value = array_keys($prompt->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($prompt->options()),
min($this->longest($prompt->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(array<int|string, string>): array<int|string, string>|Collection<int|string, string> $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
38 changes: 38 additions & 0 deletions tests/Feature/MultiSelectPromptTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,44 @@
expect($result)->toBe(['Green']);
});

it('accepts closures', function () {
Prompt::fake([Key::ENTER]);

$result = multiselect(
label: 'What are your favorite colors?',
options: fn () => collect([
'Red',
'Green',
'Blue',
]),
default: collect(['Green'])
);

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

it('can reactively change options', function () {
Prompt::fake([Key::DOWN, Key::SPACE, Key::DOWN, Key::SPACE, Key::UP, Key::SPACE, Key::SPACE, Key::ENTER]);

$result = multiselect(
label: "Pick up items (2 items slots available):",
options: fn($values) => collect([
'food' => 'Some food',
'map' => 'A map',
'jacket' => 'A life jacket',
])->when(count($values) >= 2, fn ($collection) => $collection->only($values)),
);

expect($result)->toBe(['jacket', 'food']);
});

it('throws when options are empty', function () {
multiselect(
label: 'Hey, man! About the ahh... -just hold on... (keyboard typing)',
options: fn() => [],
);
})->throws(NonInteractiveValidationException::class, 'All options are no longer available!');

it('validates', function () {
Prompt::fake([Key::ENTER, Key::SPACE, Key::ENTER]);

Expand Down

0 comments on commit aaa2692

Please sign in to comment.