diff --git a/src/ConfirmPrompt.php b/src/ConfirmPrompt.php index 351f265e..23b8a97f 100644 --- a/src/ConfirmPrompt.php +++ b/src/ConfirmPrompt.php @@ -2,8 +2,6 @@ namespace Laravel\Prompts; -use Closure; - class ConfirmPrompt extends Prompt { /** @@ -20,8 +18,8 @@ public function __construct( public string $yes = 'Yes', public string $no = 'No', public bool|string $required = false, - public ?Closure $validate = null, - public string $hint = '' + public mixed $validate = null, + public string $hint = '', ) { $this->confirmed = $default; diff --git a/src/MultiSearchPrompt.php b/src/MultiSearchPrompt.php index 5e69217d..4ba87ce9 100644 --- a/src/MultiSearchPrompt.php +++ b/src/MultiSearchPrompt.php @@ -35,7 +35,7 @@ public function __construct( public string $placeholder = '', public int $scroll = 5, public bool|string $required = false, - public ?Closure $validate = null, + public mixed $validate = null, public string $hint = '', ) { $this->trackTypedValue(submit: false, ignore: fn ($key) => Key::oneOf([Key::SPACE, Key::HOME, Key::END, Key::CTRL_A, Key::CTRL_E], $key) && $this->highlighted !== null); diff --git a/src/MultiSelectPrompt.php b/src/MultiSelectPrompt.php index 3c1e897e..b2f0c704 100644 --- a/src/MultiSelectPrompt.php +++ b/src/MultiSelectPrompt.php @@ -2,7 +2,6 @@ namespace Laravel\Prompts; -use Closure; use Illuminate\Support\Collection; class MultiSelectPrompt extends Prompt @@ -42,8 +41,8 @@ public function __construct( array|Collection $default = [], public int $scroll = 5, public bool|string $required = false, - public ?Closure $validate = null, - public string $hint = '' + public mixed $validate = null, + public string $hint = '', ) { $this->options = $options instanceof Collection ? $options->all() : $options; $this->default = $default instanceof Collection ? $default->all() : $default; diff --git a/src/PasswordPrompt.php b/src/PasswordPrompt.php index ef9e6061..31802c1e 100644 --- a/src/PasswordPrompt.php +++ b/src/PasswordPrompt.php @@ -2,8 +2,6 @@ namespace Laravel\Prompts; -use Closure; - class PasswordPrompt extends Prompt { use Concerns\TypedValue; @@ -15,8 +13,8 @@ public function __construct( public string $label, public string $placeholder = '', public bool|string $required = false, - public ?Closure $validate = null, - public string $hint = '' + public mixed $validate = null, + public string $hint = '', ) { $this->trackTypedValue(); } diff --git a/src/Prompt.php b/src/Prompt.php index eb4b7e59..1f50b102 100644 --- a/src/Prompt.php +++ b/src/Prompt.php @@ -45,9 +45,9 @@ abstract class Prompt public bool|string $required; /** - * The validator callback. + * The validator callback or rules. */ - protected ?Closure $validate; + public mixed $validate; /** * The cancellation callback. @@ -59,6 +59,11 @@ abstract class Prompt */ protected bool $validated = false; + /** + * The custom validation callback. + */ + protected static ?Closure $validateUsing; + /** * The output instance. */ @@ -190,6 +195,14 @@ public static function terminal(): Terminal return static::$terminal ??= new Terminal(); } + /** + * Set the custom validation callback. + */ + public static function validateUsing(Closure $callback): void + { + static::$validateUsing = $callback; + } + /** * Render the prompt. */ @@ -331,14 +344,18 @@ private function validate(mixed $value): void return; } - if (! isset($this->validate)) { + if (! isset($this->validate) && ! isset(static::$validateUsing)) { return; } - $error = ($this->validate)($value); + $error = match (true) { + is_callable($this->validate) => ($this->validate)($value), + isset(static::$validateUsing) => (static::$validateUsing)($this), + default => throw new RuntimeException('The validation logic is missing.'), + }; if (! is_string($error) && ! is_null($error)) { - throw new \RuntimeException('The validator must return a string or null.'); + throw new RuntimeException('The validator must return a string or null.'); } if (is_string($error) && strlen($error) > 0) { diff --git a/src/SearchPrompt.php b/src/SearchPrompt.php index 13532848..b9b820ce 100644 --- a/src/SearchPrompt.php +++ b/src/SearchPrompt.php @@ -28,7 +28,7 @@ public function __construct( public Closure $options, public string $placeholder = '', public int $scroll = 5, - public ?Closure $validate = null, + public mixed $validate = null, public string $hint = '', public bool|string $required = true, ) { diff --git a/src/SelectPrompt.php b/src/SelectPrompt.php index 6c9e5ed8..081f09c7 100644 --- a/src/SelectPrompt.php +++ b/src/SelectPrompt.php @@ -2,7 +2,6 @@ namespace Laravel\Prompts; -use Closure; use Illuminate\Support\Collection; use InvalidArgumentException; @@ -27,7 +26,7 @@ public function __construct( array|Collection $options, public int|string|null $default = null, public int $scroll = 5, - public ?Closure $validate = null, + public mixed $validate = null, public string $hint = '', public bool|string $required = true, ) { diff --git a/src/SuggestPrompt.php b/src/SuggestPrompt.php index 9e34c19b..85f2bf7b 100644 --- a/src/SuggestPrompt.php +++ b/src/SuggestPrompt.php @@ -37,8 +37,8 @@ public function __construct( public string $default = '', public int $scroll = 5, public bool|string $required = false, - public ?Closure $validate = null, - public string $hint = '' + public mixed $validate = null, + public string $hint = '', ) { $this->options = $options instanceof Collection ? $options->all() : $options; diff --git a/src/TextPrompt.php b/src/TextPrompt.php index 2588f7b0..74a41a5e 100644 --- a/src/TextPrompt.php +++ b/src/TextPrompt.php @@ -2,8 +2,6 @@ namespace Laravel\Prompts; -use Closure; - class TextPrompt extends Prompt { use Concerns\TypedValue; @@ -16,8 +14,8 @@ public function __construct( public string $placeholder = '', public string $default = '', public bool|string $required = false, - public ?Closure $validate = null, - public string $hint = '' + public mixed $validate = null, + public string $hint = '', ) { $this->trackTypedValue($default); } diff --git a/src/helpers.php b/src/helpers.php index b28d8840..d5e60b68 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -8,17 +8,17 @@ /** * Prompt the user for text input. */ -function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = ''): string +function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = ''): string { - return (new TextPrompt($label, $placeholder, $default, $required, $validate, $hint))->prompt(); + return (new TextPrompt(...func_get_args()))->prompt(); } /** * Prompt the user for input, hiding the value. */ -function password(string $label, string $placeholder = '', bool|string $required = false, ?Closure $validate = null, string $hint = ''): string +function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = ''): string { - return (new PasswordPrompt($label, $placeholder, $required, $validate, $hint))->prompt(); + return (new PasswordPrompt(...func_get_args()))->prompt(); } /** @@ -27,9 +27,9 @@ function password(string $label, string $placeholder = '', bool|string $required * @param array|Collection $options * @param true|string $required */ -function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, ?Closure $validate = null, string $hint = '', bool|string $required = true): int|string +function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true): int|string { - return (new SelectPrompt($label, $options, $default, $scroll, $validate, $hint, $required))->prompt(); + return (new SelectPrompt(...func_get_args()))->prompt(); } /** @@ -39,17 +39,17 @@ function select(string $label, array|Collection $options, int|string|null $defau * @param array|Collection $default * @return array */ -function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, ?Closure $validate = null, string $hint = 'Use the space bar to select options.'): 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 { - return (new MultiSelectPrompt($label, $options, $default, $scroll, $required, $validate, $hint))->prompt(); + return (new MultiSelectPrompt(...func_get_args()))->prompt(); } /** * Prompt the user to confirm an action. */ -function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, ?Closure $validate = null, string $hint = ''): bool +function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = ''): bool { - return (new ConfirmPrompt($label, $default, $yes, $no, $required, $validate, $hint))->prompt(); + return (new ConfirmPrompt(...func_get_args()))->prompt(); } /** @@ -57,9 +57,9 @@ function confirm(string $label, bool $default = true, string $yes = 'Yes', strin * * @param array|Collection|Closure(string): array $options */ -function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, ?Closure $validate = null, string $hint = ''): string +function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = ''): string { - return (new SuggestPrompt($label, $options, $placeholder, $default, $scroll, $required, $validate, $hint))->prompt(); + return (new SuggestPrompt(...func_get_args()))->prompt(); } /** @@ -68,9 +68,9 @@ function suggest(string $label, array|Collection|Closure $options, string $place * @param Closure(string): array $options * @param true|string $required */ -function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, ?Closure $validate = null, string $hint = '', bool|string $required = true): int|string +function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true): int|string { - return (new SearchPrompt($label, $options, $placeholder, $scroll, $validate, $hint, $required))->prompt(); + return (new SearchPrompt(...func_get_args()))->prompt(); } /** @@ -79,9 +79,9 @@ function search(string $label, Closure $options, string $placeholder = '', int $ * @param Closure(string): array $options * @return array */ -function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, ?Closure $validate = null, string $hint = 'Use the space bar to select options.'): array +function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array { - return (new MultiSearchPrompt($label, $options, $placeholder, $scroll, $required, $validate, $hint))->prompt(); + return (new MultiSearchPrompt(...func_get_args()))->prompt(); } /** diff --git a/tests/Feature/ConfirmPromptTest.php b/tests/Feature/ConfirmPromptTest.php index 627b077a..79d703fd 100644 --- a/tests/Feature/ConfirmPromptTest.php +++ b/tests/Feature/ConfirmPromptTest.php @@ -118,3 +118,21 @@ required: true, ); })->throws(NonInteractiveValidationException::class, 'Required.'); + +it('supports custom validation', function () { + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('Are you sure?') + ->validate->toBe('confirmed'); + + return $prompt->validate === 'confirmed' && ! $prompt->value() ? 'Need to be sure!' : null; + }); + + Prompt::fake([Key::DOWN, Key::ENTER, Key::UP, Key::ENTER]); + + confirm(label: 'Are you sure?', validate: 'confirmed'); + + Prompt::assertOutputContains('Need to be sure!'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/MultiSearchPromptTest.php b/tests/Feature/MultiSearchPromptTest.php index 4149b329..94bdcf60 100644 --- a/tests/Feature/MultiSearchPromptTest.php +++ b/tests/Feature/MultiSearchPromptTest.php @@ -184,3 +184,31 @@ expect($result)->toBe(['result']); }); + +it('supports custom validation', function () { + Prompt::fake(['a', Key::DOWN, Key::SPACE, Key::ENTER, Key::DOWN, Key::SPACE, Key::ENTER]); + + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What are your favorite colors?') + ->validate->toBe('in:green'); + + return $prompt->validate === 'in:green' && ! in_array('green', $prompt->value()) ? 'And green?' : null; + }); + + $result = multisearch( + label: 'What are your favorite colors?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + validate: 'in:green', + ); + + expect($result)->toBe(['red', 'green']); + + Prompt::assertOutputContains('And green?'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/MultiSelectPromptTest.php b/tests/Feature/MultiSelectPromptTest.php index bb6ef755..53b9cbc8 100644 --- a/tests/Feature/MultiSelectPromptTest.php +++ b/tests/Feature/MultiSelectPromptTest.php @@ -203,3 +203,31 @@ 'Blue', ], required: true); })->throws(NonInteractiveValidationException::class, 'Required.'); + +it('supports custom validation', function () { + Prompt::fake([Key::SPACE, Key::ENTER, Key::DOWN, Key::SPACE, Key::ENTER]); + + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What are your favorite colors?') + ->validate->toBe('in:green'); + + return $prompt->validate === 'in:green' && ! in_array('green', $prompt->value()) ? 'And green?' : null; + }); + + $result = multiselect( + label: 'What are your favorite colors?', + options: [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + validate: 'in:green', + ); + + expect($result)->toBe(['red', 'green']); + + Prompt::assertOutputContains('And green?'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/PasswordPromptTest.php b/tests/Feature/PasswordPromptTest.php index 691309fb..795e9e77 100644 --- a/tests/Feature/PasswordPromptTest.php +++ b/tests/Feature/PasswordPromptTest.php @@ -79,3 +79,26 @@ password('What is the password?', required: true); })->throws(NonInteractiveValidationException::class, 'Required.'); + +it('supports custom validation', function () { + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What is the password?') + ->validate->toBe('min:8'); + + return $prompt->validate === 'min:8' && strlen($prompt->value()) < 8 ? 'Minimum 8 chars!' : null; + }); + + Prompt::fake(['p', Key::ENTER, 'a', 's', 's', 'w', 'o', 'r', 'd', Key::ENTER]); + + $result = password( + label: 'What is the password?', + validate: 'min:8', + ); + + expect($result)->toBe('password'); + + Prompt::assertOutputContains('Minimum 8 chars!'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/SearchPromptTest.php b/tests/Feature/SearchPromptTest.php index f0d50cb3..cf196a9c 100644 --- a/tests/Feature/SearchPromptTest.php +++ b/tests/Feature/SearchPromptTest.php @@ -165,3 +165,31 @@ search('What is your favorite color?', fn () => [], required: 'The color is required.'); })->throws(NonInteractiveValidationException::class, 'The color is required.'); + +it('supports custom validation', function () { + Prompt::fake([Key::DOWN, Key::ENTER, Key::DOWN, Key::ENTER]); + + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What is your favorite color?') + ->validate->toBe('in:green'); + + return $prompt->validate === 'in:green' && $prompt->value() != 'green' ? 'Please choose green.' : null; + }); + + $result = search( + label: 'What is your favorite color?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + validate: 'in:green', + ); + + expect($result)->toBe('green'); + + Prompt::assertOutputContains('Please choose green.'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/SelectPromptTest.php b/tests/Feature/SelectPromptTest.php index 6d57d3fb..32b4deaa 100644 --- a/tests/Feature/SelectPromptTest.php +++ b/tests/Feature/SelectPromptTest.php @@ -322,3 +322,31 @@ required: 'The color is required.', ); })->throws(NonInteractiveValidationException::class, 'The color is required.'); + +it('supports custom validation', function () { + Prompt::fake([Key::ENTER, Key::DOWN, Key::ENTER]); + + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What is your favorite color?') + ->validate->toBe('in:green'); + + return $prompt->validate === 'in:green' && $prompt->value() != 'green' ? 'Please choose green.' : null; + }); + + $result = select( + label: 'What is your favorite color?', + options: [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + validate: 'in:green', + ); + + expect($result)->toBe('green'); + + Prompt::assertOutputContains('Please choose green.'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/SuggestPromptTest.php b/tests/Feature/SuggestPromptTest.php index 32a90365..e0eae8e1 100644 --- a/tests/Feature/SuggestPromptTest.php +++ b/tests/Feature/SuggestPromptTest.php @@ -176,3 +176,27 @@ 'Blue', ], required: true); })->throws(NonInteractiveValidationException::class, 'Required.'); + +it('supports custom validation', function () { + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What is your name?') + ->validate->toBe('min:2'); + + return $prompt->validate === 'min:2' && strlen($prompt->value()) < 2 ? 'Minimum 2 chars!' : null; + }); + + Prompt::fake(['A', Key::ENTER, 'n', 'd', 'r', 'e', 'a', Key::ENTER]); + + $result = suggest( + label: 'What is your name?', + options: ['Jess', 'Taylor'], + validate: 'min:2', + ); + + expect($result)->toBe('Andrea'); + + Prompt::assertOutputContains('Minimum 2 chars!'); + + Prompt::validateUsing(fn () => null); +}); diff --git a/tests/Feature/TextPromptTest.php b/tests/Feature/TextPromptTest.php index caec6f5f..2f47b46d 100644 --- a/tests/Feature/TextPromptTest.php +++ b/tests/Feature/TextPromptTest.php @@ -115,6 +115,29 @@ text('What is your name?', required: true); })->throws(NonInteractiveValidationException::class, 'Required.'); +it('supports custom validation', function () { + Prompt::validateUsing(function (Prompt $prompt) { + expect($prompt) + ->label->toBe('What is your name?') + ->validate->toBe('min:2'); + + return $prompt->validate === 'min:2' && strlen($prompt->value()) < 2 ? 'Minimum 2 chars!' : null; + }); + + Prompt::fake(['J', Key::ENTER, 'e', 's', 's', Key::ENTER]); + + $result = text( + label: 'What is your name?', + validate: 'min:2', + ); + + expect($result)->toBe('Jess'); + + Prompt::assertOutputContains('Minimum 2 chars!'); + + Prompt::validateUsing(fn () => null); +}); + it('allows customizing the cancellation', function () { Prompt::cancelUsing(fn () => throw new Exception('Cancelled.')); Prompt::fake([Key::CTRL_C]);