From 4d2d574026eed1d3e33ebda82fa95b3f6d93c53b Mon Sep 17 00:00:00 2001 From: GoodM4ven Date: Wed, 21 Feb 2024 18:41:36 +0300 Subject: [PATCH 1/2] [Feature] Reactive multi-select options through closure, plus auto-validation Changes: - Added the ability to provide multiselect options as a closure - Created an evaluater for the closer to be called wherever the options are needed - Added a test for the feature, then tested everything - Made the multi-select trigger upon selection (space key) - Exposed validate method for that to happen Concerns: - Should there be a config check for whether to do the validation instantly? Or why doesn't validation happens upon selection by default? - Honestly, I can't seem to understand how would I trigger the option re-evaluation without triggering validation first! --- src/MultiSelectPrompt.php | 59 ++++++++++++++----- src/Prompt.php | 2 +- .../Default/MultiSelectPromptRenderer.php | 22 ++++--- src/helpers.php | 4 +- tests/Feature/MultiSelectPromptTest.php | 16 +++++ 5 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/MultiSelectPrompt.php b/src/MultiSelectPrompt.php index b2f0c704..98aace56 100644 --- a/src/MultiSelectPrompt.php +++ b/src/MultiSelectPrompt.php @@ -2,6 +2,7 @@ namespace Laravel\Prompts; +use Closure; use Illuminate\Support\Collection; class MultiSelectPrompt extends Prompt @@ -11,9 +12,9 @@ class MultiSelectPrompt extends Prompt /** * The options for the multi-select prompt. * - * @var array + * @var array|Closure */ - public array $options; + public array|Closure $options; /** * The default values the multi-select prompt. @@ -32,12 +33,12 @@ class MultiSelectPrompt extends Prompt /** * Create a new MultiSelectPrompt instance. * - * @param array|Collection $options + * @param array|Collection|Closure $options * @param array|Collection $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, @@ -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, @@ -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))); } /** @@ -92,7 +97,12 @@ 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, + ); } /** @@ -100,11 +110,13 @@ public function visible(): array */ 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; } /** @@ -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|Collection|Closure $options + * @return array + */ + public function eval(array|Collection|Closure $options): array + { + return ($options = value($options)) instanceof Collection ? $options->toArray() : (array) $options; } } diff --git a/src/Prompt.php b/src/Prompt.php index 1f50b102..9759ef45 100644 --- a/src/Prompt.php +++ b/src/Prompt.php @@ -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; diff --git a/src/Themes/Default/MultiSelectPromptRenderer.php b/src/Themes/Default/MultiSelectPromptRenderer.php index 6f560685..449c6300 100644 --- a/src/Themes/Default/MultiSelectPromptRenderer.php +++ b/src/Themes/Default/MultiSelectPromptRenderer.php @@ -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( @@ -35,7 +37,7 @@ 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)), @@ -43,7 +45,7 @@ public function __invoke(MultiSelectPrompt $prompt): string ->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, @@ -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()); @@ -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); } diff --git a/src/helpers.php b/src/helpers.php index d5e60b68..2bc07c7f 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -35,11 +35,11 @@ function select(string $label, array|Collection $options, int|string|null $defau /** * Prompt the user to select multiple options. * - * @param array|Collection $options + * @param array|Collection|Closure $options * @param array|Collection $default * @return array */ -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(); } diff --git a/tests/Feature/MultiSelectPromptTest.php b/tests/Feature/MultiSelectPromptTest.php index 53b9cbc8..ad4f5121 100644 --- a/tests/Feature/MultiSelectPromptTest.php +++ b/tests/Feature/MultiSelectPromptTest.php @@ -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']); +}); From 9112777113b792217ebf7b0e3fbf1e8d02ba763c Mon Sep 17 00:00:00 2001 From: GoodM4ven Date: Wed, 21 Feb 2024 19:02:22 +0300 Subject: [PATCH 2/2] Fixed doc-block return type warning for PHPStan --- src/MultiSelectPrompt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MultiSelectPrompt.php b/src/MultiSelectPrompt.php index 98aace56..63d23fe8 100644 --- a/src/MultiSelectPrompt.php +++ b/src/MultiSelectPrompt.php @@ -151,7 +151,7 @@ protected function toggleHighlighted(): void * Returns options as an array; and re-evaluates them if they were in a closure * * @param array|Collection|Closure $options - * @return array + * @return array */ public function eval(array|Collection|Closure $options): array {