diff --git a/composer.json b/composer.json index b7de309..b41f65b 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,20 @@ "require": { "symfony/console": "6.3.*", "symfony/yaml": "6.3.*", - "twig/twig": "3.8.*" + "twig/twig": "3.8.*", + "laravel/prompts": "0.1.*", + "cweagans/composer-patches": "^1.7" + }, + "config": { + "allow-plugins": { + "cweagans/composer-patches": true + } + }, + "extra": { + "patches": { + "laravel/prompts": { + "Add textarea, a multiline text input": "./patches/laravel-prompts/88.diff" + } + } } } diff --git a/patches/laravel-prompts/88.diff b/patches/laravel-prompts/88.diff new file mode 100644 index 0000000..ea729d6 --- /dev/null +++ b/patches/laravel-prompts/88.diff @@ -0,0 +1,626 @@ +diff --git a/playground/textarea.php b/playground/textarea.php +new file mode 100644 +index 0000000..070305f +--- /dev/null ++++ b/playground/textarea.php +@@ -0,0 +1,14 @@ ++ [ + TextPrompt::class => TextPromptRenderer::class, ++ TextareaPrompt::class => TextareaPromptRenderer::class, + PasswordPrompt::class => PasswordPromptRenderer::class, + SelectPrompt::class => SelectPromptRenderer::class, + MultiSelectPrompt::class => MultiSelectPromptRenderer::class, +diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php +index afde293..33ff0c5 100644 +--- a/src/Concerns/TypedValue.php ++++ b/src/Concerns/TypedValue.php +@@ -19,7 +19,7 @@ trait TypedValue + /** + * Track the value as the user types. + */ +- protected function trackTypedValue(string $default = '', bool $submit = true, callable $ignore = null): void ++ protected function trackTypedValue(string $default = '', bool $submit = true, callable $ignore = null, bool $allowNewLine = false): void + { + $this->typedValue = $default; + +@@ -27,7 +27,7 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca + $this->cursorPosition = mb_strlen($this->typedValue); + } + +- $this->on('key', function ($key) use ($submit, $ignore) { ++ $this->on('key', function ($key) use ($submit, $ignore, $allowNewLine) { + if ($key[0] === "\e" || in_array($key, [Key::CTRL_B, Key::CTRL_F, Key::CTRL_A, Key::CTRL_E])) { + if ($ignore !== null && $ignore($key)) { + return; +@@ -51,10 +51,17 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca + return; + } + +- if ($key === Key::ENTER && $submit) { +- $this->submit(); ++ if ($key === Key::ENTER) { ++ if ($submit) { ++ $this->submit(); + +- return; ++ return; ++ } ++ ++ if ($allowNewLine) { ++ $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).PHP_EOL.mb_substr($this->typedValue, $this->cursorPosition); ++ $this->cursorPosition++; ++ } + } elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) { + if ($this->cursorPosition === 0) { + return; +@@ -87,14 +94,14 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): + $current = mb_substr($value, $cursorPosition, 1); + $after = mb_substr($value, $cursorPosition + 1); + +- $cursor = mb_strlen($current) ? $current : ' '; ++ $cursor = mb_strlen($current) && $current !== PHP_EOL ? $current : ' '; + +- $spaceBefore = $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0); ++ $spaceBefore = $maxWidth < 0 ? mb_strwidth($before) : $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0); + [$truncatedBefore, $wasTruncatedBefore] = mb_strwidth($before) > $spaceBefore + ? [$this->trimWidthBackwards($before, 0, $spaceBefore - 1), true] + : [$before, false]; + +- $spaceAfter = $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor); ++ $spaceAfter = $maxWidth < 0 ? mb_strwidth($after) : $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor); + [$truncatedAfter, $wasTruncatedAfter] = mb_strwidth($after) > $spaceAfter + ? [mb_strimwidth($after, 0, $spaceAfter - 1), true] + : [$after, false]; +@@ -102,6 +109,7 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): + return ($wasTruncatedBefore ? $this->dim('…') : '') + .$truncatedBefore + .$this->inverse($cursor) ++ .($current === PHP_EOL ? PHP_EOL : '') + .$truncatedAfter + .($wasTruncatedAfter ? $this->dim('…') : ''); + } +diff --git a/src/Key.php b/src/Key.php +index bfbe2c9..48827d6 100644 +--- a/src/Key.php ++++ b/src/Key.php +@@ -71,6 +71,11 @@ class Key + */ + const CTRL_A = "\x01"; + ++ /** ++ * EOF ++ */ ++ const CTRL_D = "\x04"; ++ + /** + * End + */ +diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php +new file mode 100644 +index 0000000..9225f89 +--- /dev/null ++++ b/src/TextareaPrompt.php +@@ -0,0 +1,207 @@ ++trackTypedValue( ++ default: $default, ++ submit: false, ++ allowNewLine: true, ++ ); ++ ++ $this->scroll = $this->rows; ++ ++ $this->initializeScrolling(); ++ ++ $this->on( ++ 'key', ++ function ($key) { ++ if ($key[0] === "\e") { ++ match ($key) { ++ Key::UP, Key::UP_ARROW, Key::CTRL_P => $this->handleUpKey(), ++ Key::DOWN, Key::DOWN_ARROW, Key::CTRL_N => $this->handleDownKey(), ++ default => null, ++ }; ++ ++ return; ++ } ++ ++ // Keys may be buffered. ++ foreach (mb_str_split($key) as $key) { ++ if ($key === Key::CTRL_D) { ++ $this->submit(); ++ ++ return; ++ } ++ } ++ } ++ ); ++ } ++ ++ /** ++ * Handle the up keypress. ++ */ ++ protected function handleUpKey(): void ++ { ++ if ($this->cursorPosition === 0) { ++ return; ++ } ++ ++ $lines = collect($this->lines()); ++ ++ // Line length + 1 for the newline character ++ $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); ++ ++ $currentLineIndex = $this->currentLineIndex(); ++ ++ if ($currentLineIndex === 0) { ++ // They're already at the first line, jump them to the first position ++ $this->cursorPosition = 0; ++ ++ return; ++ } ++ ++ $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); ++ ++ $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); ++ ++ $destinationLineLength = $lineLengths->get($currentLineIndex - 1) ?? $currentLines->first(); ++ ++ $newColumn = min($destinationLineLength, $currentColumn); ++ ++ if ($newColumn < $currentColumn) { ++ $newColumn--; ++ } ++ ++ $fullLines = $currentLines->slice(0, -2); ++ ++ $this->cursorPosition = $fullLines->sum() + $newColumn; ++ } ++ ++ /** ++ * Handle the down keypress. ++ */ ++ protected function handleDownKey(): void ++ { ++ $lines = collect($this->lines()); ++ ++ // Line length + 1 for the newline character ++ $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); ++ ++ $currentLineIndex = $this->currentLineIndex(); ++ ++ if ($currentLineIndex === $lines->count() - 1) { ++ // They're already at the last line, jump them to the last position ++ $this->cursorPosition = mb_strlen($lines->implode(PHP_EOL)); ++ ++ return; ++ } ++ ++ // Lines up to and including the current line ++ $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); ++ ++ $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); ++ ++ $destinationLineLength = ($lineLengths->get($currentLineIndex + 1) ?? $currentLines->last()) - 1; ++ ++ $newColumn = min(max(0, $destinationLineLength), $currentColumn); ++ ++ $this->cursorPosition = $currentLines->sum() + $newColumn; ++ } ++ ++ /** ++ * The currently visible options. ++ * ++ * @return array ++ */ ++ public function visible(): array ++ { ++ $this->adjustVisibleWindow(); ++ ++ $withCursor = $this->valueWithCursor(10_000); ++ ++ return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); ++ } ++ ++ protected function adjustVisibleWindow(): void ++ { ++ if (count($this->lines()) < $this->scroll) { ++ return; ++ } ++ ++ $currentLineIndex = $this->currentLineIndex(); ++ ++ if ($this->firstVisible + $this->scroll <= $currentLineIndex) { ++ $this->firstVisible++; ++ } ++ ++ if ($currentLineIndex === $this->firstVisible - 1) { ++ $this->firstVisible = max(0, $this->firstVisible - 1); ++ } ++ ++ // Make sure there are always the scroll amount visible ++ if ($this->firstVisible + $this->scroll > count($this->lines())) { ++ $this->firstVisible = count($this->lines()) - $this->scroll; ++ } ++ } ++ ++ /** ++ * Get the index of the current line that the cursor is on. ++ */ ++ protected function currentLineIndex(): int ++ { ++ $totalLineLength = 0; ++ ++ return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) { ++ $totalLineLength += mb_strlen($line) + 1; ++ ++ return $totalLineLength > $this->cursorPosition; ++ }) ?: 0; ++ } ++ ++ /** ++ * Get the formatted lines of the current value. ++ * ++ * @return array ++ */ ++ public function lines(): array ++ { ++ $value = wordwrap($this->value(), $this->width - 1, PHP_EOL, true); ++ ++ return explode(PHP_EOL, $value); ++ } ++ ++ /** ++ * Get the formatted value with a virtual cursor. ++ */ ++ public function valueWithCursor(int $maxWidth): string ++ { ++ $value = implode(PHP_EOL, $this->lines()); ++ ++ if ($this->value() === '') { ++ return $this->dim($this->addCursor($this->placeholder, 0, 10_000)); ++ } ++ ++ return $this->addCursor($value, $this->cursorPosition, -1); ++ } ++} +diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php +new file mode 100644 +index 0000000..4afb9f8 +--- /dev/null ++++ b/src/Themes/Default/TextareaPromptRenderer.php +@@ -0,0 +1,85 @@ ++width = min($this->minWidth, $prompt->terminal()->cols() - 6); ++ ++ return match ($prompt->state) { ++ 'submit' => $this ++ ->box( ++ $this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), ++ collect($prompt->lines())->implode(PHP_EOL), ++ ), ++ ++ 'cancel' => $this ++ ->box( ++ $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), ++ collect($prompt->lines())->map(fn ($line) => $this->strikethrough($this->dim($line)))->implode(PHP_EOL), ++ color: 'red', ++ ) ++ ->error('Cancelled.'), ++ ++ 'error' => $this ++ ->box( ++ $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), ++ $this->renderText($prompt), ++ color: 'yellow', ++ info: 'Ctrl+D to submit' ++ ) ++ ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), ++ ++ default => $this ++ ->box( ++ $this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), ++ $this->renderText($prompt), ++ info: 'Ctrl+D to submit' ++ ) ++ ->when( ++ $prompt->hint, ++ fn () => $this->hint($prompt->hint), ++ fn () => $this->newLine() // Space for errors ++ ) ++ }; ++ } ++ ++ /** ++ * Render the text in the prompt. ++ */ ++ protected function renderText(TextareaPrompt $prompt): string ++ { ++ $visible = collect($prompt->visible()); ++ ++ while ($visible->count() < $prompt->scroll) { ++ $visible->push(''); ++ } ++ ++ return $this->scrollbar( ++ $visible, ++ $prompt->firstVisible, ++ $prompt->scroll, ++ count($prompt->lines()), ++ $prompt->width, ++ )->implode(PHP_EOL); ++ } ++ ++ /** ++ * The number of lines to reserve outside of the scrollable area. ++ */ ++ public function reservedLines(): int ++ { ++ return 5; ++ } ++} +diff --git a/src/helpers.php b/src/helpers.php +index 178cb22..0219d60 100644 +--- a/src/helpers.php ++++ b/src/helpers.php +@@ -13,6 +13,14 @@ function text(string $label, string $placeholder = '', string $default = '', boo + return (new TextPrompt($label, $placeholder, $default, $required, $validate, $hint))->prompt(); + } + ++/** ++ * Prompt the user for multiline text input. ++ */ ++function textarea(string $label, int $rows = 5, string $placeholder = '', string $default = '', bool|string $required = false, Closure $validate = null, string $hint = ''): string ++{ ++ return (new TextareaPrompt($label, $rows, $placeholder, $default, $required, $validate, $hint))->prompt(); ++} ++ + /** + * Prompt the user for input, hiding the value. + */ +diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php +new file mode 100644 +index 0000000..0b93b1a +--- /dev/null ++++ b/tests/Feature/TextareaPromptTest.php +@@ -0,0 +1,164 @@ ++toBe("Jess\nJoe"); ++}); ++ ++it('accepts a default value', function () { ++ Prompt::fake([Key::CTRL_D]); ++ ++ $result = textarea( ++ label: 'What is your name?', ++ default: "Jess\nJoe" ++ ); ++ ++ expect($result)->toBe("Jess\nJoe"); ++}); ++ ++it('validates', function () { ++ Prompt::fake(['J', 'e', 's', Key::CTRL_D, 's', Key::CTRL_D]); ++ ++ $result = textarea( ++ label: 'What is your name?', ++ validate: fn ($value) => $value !== 'Jess' ? 'Invalid name.' : '', ++ ); ++ ++ expect($result)->toBe('Jess'); ++ ++ Prompt::assertOutputContains('Invalid name.'); ++}); ++ ++it('cancels', function () { ++ Prompt::fake([Key::CTRL_C]); ++ ++ textarea(label: 'What is your name?'); ++ ++ Prompt::assertOutputContains('Cancelled.'); ++}); ++ ++test('the backspace key removes a character', function () { ++ Prompt::fake(['J', 'e', 'z', Key::BACKSPACE, 's', 's', Key::CTRL_D]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe('Jess'); ++}); ++ ++test('the delete key removes a character', function () { ++ Prompt::fake(['J', 'e', 'z', Key::LEFT, Key::DELETE, 's', 's', Key::CTRL_D]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe('Jess'); ++}); ++ ++it('can fall back', function () { ++ Prompt::fallbackWhen(true); ++ ++ TextareaPrompt::fallbackUsing(function (TextareaPrompt $prompt) { ++ expect($prompt->label)->toBe('What is your name?'); ++ ++ return 'result'; ++ }); ++ ++ $result = textarea('What is your name?'); ++ ++ expect($result)->toBe('result'); ++}); ++ ++it('supports emacs style key bindings', function () { ++ Prompt::fake(['J', 'z', 'e', Key::CTRL_B, Key::CTRL_H, key::CTRL_F, 's', 's', Key::CTRL_D]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe('Jess'); ++}); ++ ++it('moves to the beginning and end of line', function () { ++ Prompt::fake(['e', 's', Key::HOME[0], 'J', KEY::END[0], 's', Key::CTRL_D]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe('Jess'); ++}); ++ ++it('moves up and down lines', function () { ++ Prompt::fake([ ++ 'e', 's', 's', Key::ENTER, 'o', 'e', ++ KEY::UP_ARROW, KEY::LEFT_ARROW, Key::LEFT_ARROW, ++ 'J', KEY::DOWN_ARROW, KEY::LEFT_ARROW, 'J', Key::CTRL_D, ++ ]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe("Jess\nJoe"); ++}); ++ ++it('moves to the start of the line if up is pressed twice on the first line', function () { ++ Prompt::fake([ ++ 'e', 's', 's', Key::ENTER, 'J', 'o', 'e', ++ KEY::UP_ARROW, KEY::UP_ARROW, 'J', Key::CTRL_D, ++ ]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe("Jess\nJoe"); ++}); ++ ++it('moves to the end of the line if down is pressed twice on the last line', function () { ++ Prompt::fake([ ++ 'J', 'e', 's', 's', Key::ENTER, 'J', 'o', ++ KEY::UP_ARROW, KEY::UP_ARROW, Key::DOWN_ARROW, ++ Key::DOWN_ARROW, 'e', Key::CTRL_D, ++ ]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe("Jess\nJoe"); ++}); ++ ++it('can move back to the last line when it is empty', function () { ++ Prompt::fake([ ++ 'J', 'e', 's', 's', Key::ENTER, ++ Key::UP, Key::DOWN, ++ 'J', 'o', 'e', ++ Key::CTRL_D, ++ ]); ++ ++ $result = textarea(label: 'What is your name?'); ++ ++ expect($result)->toBe("Jess\nJoe"); ++}); ++ ++it('returns an empty string when non-interactive', function () { ++ Prompt::interactive(false); ++ ++ $result = textarea('What is your name?'); ++ ++ expect($result)->toBe(''); ++}); ++ ++it('returns the default value when non-interactive', function () { ++ Prompt::interactive(false); ++ ++ $result = textarea('What is your name?', default: 'Taylor'); ++ ++ expect($result)->toBe('Taylor'); ++}); ++ ++it('validates the default value when non-interactive', function () { ++ Prompt::interactive(false); ++ ++ textarea('What is your name?', required: true); ++})->throws(NonInteractiveValidationException::class, 'Required.'); diff --git a/src/Console/Command/AddCommand.php b/src/Console/Command/AddCommand.php index b9052dc..e47e268 100644 --- a/src/Console/Command/AddCommand.php +++ b/src/Console/Command/AddCommand.php @@ -8,8 +8,12 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; -use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\select; +use function Laravel\Prompts\suggest; +use function Laravel\Prompts\text; +use function Laravel\Prompts\textarea; /** * Add command. @@ -26,6 +30,13 @@ class AddCommand extends Command { */ const UNDEFINED_PERSON= 'New person'; + /** + * Key for undefined person. + * + * @var string + */ + const UNDEFINED_PERSON_KEY = 0; + /** * Label for undefined project. * @@ -63,18 +74,23 @@ protected function execute(InputInterface $input, OutputInterface $output) { } catch (\InvalidArgumentException $exception) { // File does not exist yet. - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion(sprintf('The "%s" file does not exist yet. Do you want to generate initialize it? (y/n) ', $yml_file), false); - if (!$helper->ask($input, $output, $question)) { + $create_file = confirm( + label: sprintf('The "%s" file does not exist yet. Do you want to generate it? (y/n) ', $yml_file), + default: false, + hint: 'A contributions file is needed to continue.' + ); + if (!$create_file) { // Nothing else to do, cannot continue, hence fail. - $output->writeln('A contributions YAML file is needed to continue. See an examples directory or accept to generate it while runnind add command.'); + $output->writeln('A contributions YAML file is needed to continue.'); + $output->writeln('See an examples directory or accept to generate it while running the add command.'); return Command::FAILURE; } $this->generateMinimalYaml(); - $this->getOrganization($input, $output); + $this->getOrganization(); } - $project = $this->getProject($input, $output); + $project = $this->getProject(); $contribution = $this->getContribution($project, $input, $output); + $contribution = $this->getContribution($project); $this->contributions['contributions'][] = $contribution; $this->writeYaml($yml_file, $output); return Command::SUCCESS; @@ -83,22 +99,23 @@ protected function execute(InputInterface $input, OutputInterface $output) { /** * Helper to get organization. * - * @param \Symfony\Component\Console\Input\InputInterface $input - * Input object. - * @param \Symfony\Component\Console\Output\OutputInterface $output - * Output object. - * * @return array * A map with two keys, name and url, for the organization. */ - protected function getOrganization(InputInterface $input, OutputInterface $output) { - $helper = $this->getHelper('question'); - $question = new Question('[1/2] What is the name of the organization? '); - $question->setValidator([self::class, 'isNotEmpty']); - $name = $helper->ask($input, $output, $question); - $question = new Question('What is main URL for the organization? '); - $question->setValidator([self::class, 'isNotEmpty']); - $url = $helper->ask($input, $output, $question); + protected function getOrganization() { + $not_empty = self::getNotEmptyClosure(); + $name = text( + label: '[1/2] What is the name of the organization?', + placeholder: 'Acme Inc', + required: true, + validate: $not_empty + ); + $url = text( + label: '[2/2] What is main URL for the organization?', + placeholder: 'https://example.org', + required: true, + validate: $not_empty + ); $this->contributions['organization'] = [ 'name' => $name, 'url' => $url, @@ -109,44 +126,56 @@ protected function getOrganization(InputInterface $input, OutputInterface $outpu /** * Helper to get related project. * - * @param \Symfony\Component\Console\Input\InputInterface $input - * Input object. - * @param \Symfony\Component\Console\Output\OutputInterface $output - * Output object. - * * @return string * The project to use, e.g. drupal/migrate_plus. */ - protected function getProject(InputInterface $input, OutputInterface $output) { - $helper = $this->getHelper('question'); - $projects = [self::UNDEFINED_PROJECT_KEY => self::UNDEFINED_PROJECT]; + protected function getProject() { + $projects = [self::UNDEFINED_PROJECT_KEY => sprintf('%s (%s)', self::UNDEFINED_PROJECT, self::UNDEFINED_PROJECT_KEY)]; ksort($this->contributions['projects']); foreach ($this->contributions['projects'] as $project_key => $project) { - $projects[$project_key] = $project['name']; + $projects[$project_key] = sprintf('%s (%s)', $project['name'], $project_key); } - $question = new ChoiceQuestion( - '[1/7] Which project received the contribution?', - $projects, - 0 + $project_search_item = suggest( + label: '[1/7] Which project received the contribution?', + options: fn (string $value) => match (true) { + empty($value) => $projects, + default => array_filter($projects, function ($v, $k) use ($value) { + return str_contains($v, $value); + }, ARRAY_FILTER_USE_BOTH), + }, + validate: fn (string $value) => match (true) { + in_array($value, $projects) => null, + default => sprintf('Project "%s" is invalid.', $value), + } ); - $question->setErrorMessage('Project %s is invalid.'); - $project = $helper->ask($input, $output, $question); + $project = array_search($project_search_item, $projects); if ($project == self::UNDEFINED_PROJECT_KEY) { - $question = new Question('What is the machine name of the project? (E.g. drupal/migrate_plus): '); - $question->setValidator([self::class, 'isNotEmpty']); - $machine_name = $helper->ask($input, $output, $question); - $question->setValidator([self::class, 'isNotEmpty']); - $question = new Question('What is the name of the project? (E.g. Migrate Plus): '); - $question->setValidator([self::class, 'isNotEmpty']); - $name = $helper->ask($input, $output, $question); - $question = new Question('What is the main URL of the project? (E.g. https://www.drupal.org/project/migrate_plus): '); - $question->setValidator([self::class, 'isNotEmpty']); - $url = $helper->ask($input, $output, $question); - $question = new Question("Please provide tags for the project, e.g. drupal (one per line)\nUse EOL to finish, e.g. Ctrl+D on an empty line to finish input\n"); - $question->setMultiline(true); - $question->setValidator([self::class, 'isNotEmpty']); - $question->setNormalizer([self::class, 'cleanEmpty']); - $tags = $helper->ask($input, $output, $question); + $not_empty = self::getNotEmptyClosure(); + $machine_name = text( + label: 'What is the machine name of the project?', + placeholder: 'drupal/migrate_plus', + required: true, + validate: $not_empty + ); + $name = text( + label: 'What is the name of the project?', + placeholder: 'Migrate Plus', + required: true, + validate: $not_empty + ); + $url = text( + label: 'What is the main URL of the project?', + placeholder: 'https://www.drupal.org/project/migrate_plus', + required: true, + validate: $not_empty + ); + $tags = textarea( + label: 'Please provide tags for the project', + placeholder: 'drupal', + hint: 'One tag per line', + validate: $not_empty + ); + $tags = self::cleanEmpty($tags); $this->contributions['projects'][$machine_name] = [ 'name' => $name, 'url' => $url, @@ -162,78 +191,87 @@ protected function getProject(InputInterface $input, OutputInterface $output) { * * @param string $project * The project to use, e.g. drupal/migrate_plus. - * @param \Symfony\Component\Console\Input\InputInterface $input - * Input object. - * @param \Symfony\Component\Console\Output\OutputInterface $output - * Output object. * * @return array * The contribution data. */ - protected function getContribution(string $project, InputInterface $input, OutputInterface $output) { - $helper = $this->getHelper('question'); + protected function getContribution(string $project) { $contribution = ['project' => $project]; - $question = new Question('[2/7] Please give the contribution a title: '); - $question->setValidator([self::class, 'isNotEmpty']); - $contribution['title'] = $helper->ask($input, $output, $question); - $question = new ChoiceQuestion( - '[3/7] What was the main type of the contribution? [default: code]', - $this->getContributionTypes(), - 'code' + $not_empty = self::getNotEmptyClosure(); + $contribution['title'] = text( + label: '[2/7] Title', + hint: 'Please give the contribution a title.', + required: true, + validate: $not_empty + ); + $contribution['type'] = select( + label: '[2/7] Type', + hint: 'What was the main type of the contribution?', + options: $this->getContributionTypes(), + default: 'code', + required: true, ); - $question->setErrorMessage('Type %s is invalid.'); - $contribution['type'] = $helper->ask($input, $output, $question); - $contribution['who'] = $this->getPerson($input, $output); + $contribution['who'] = $this->getPerson(); $today = date('Y-m-d'); - $question = new Question("[5/7] When was the contribution first published? (E.g. 2020-01-22) [default: $today]: ", $today); - $question->setValidator(function ($value) { - if (empty($value) || strtotime($value) === FALSE) { - throw new \RuntimeException('Invalid date. Please provide a time string like 2020-01-22.'); - } - return new \Datetime('@' . strtotime($value), new \DateTimeZone('UTC')); - }); - $contribution['start'] = $helper->ask($input, $output, $question); - $question = new Question("[6/7] How would you describe the contribution? (multiline)\nUse EOL to finish, e.g. Ctrl+D on an empty line to finish input\n"); - $question->setMultiline(true); - $question->setValidator([self::class, 'isNotEmpty']); - $contribution['description'] = $helper->ask($input, $output, $question); - $question = new Question("[7/7] Please provide public links related to the contribution? (one per line)\nUse EOL to finish, e.g. Ctrl+D on an empty line to finish input\n"); - $question->setMultiline(true); - $question->setValidator([self::class, 'isNotEmpty']); - $question->setNormalizer([self::class, 'cleanEmpty']); - $contribution['links'] = $helper->ask($input, $output, $question); + $start = text( + label: '[5/7] Date', + hint: 'When was the contribution first published?', + default: $today, + required: true, + validate: function ($value) { + if (empty($value) || strtotime($value) === FALSE) { + return 'Invalid date. Please provide a time string like 2020-01-22.'; + } + }, + ); + $contribution['start'] = new \Datetime('@' . strtotime($start), new \DateTimeZone('UTC')); + $contribution['description'] = textarea( + label: '[6/7] Description', + hint: 'How would you describe the contribution?', + validate: $not_empty, + ); + $links = textarea( + label: '[7/7] Links', + hint: 'Please provide public links related to the contribution? (one per line)', + validate: $not_empty, + ); + $contribution['links'] = self::cleanEmpty($links); return $contribution; } /** * Helper to get related contributor. * - * @param \Symfony\Component\Console\Input\InputInterface $input - * Input object. - * @param \Symfony\Component\Console\Output\OutputInterface $output - * Output object. - * * @return string - * The project to use, e.g. drupal/migrate_plus. + * The person identifier, e.g. jsaramago. */ - protected function getPerson(InputInterface $input, OutputInterface $output) { - $helper = $this->getHelper('question'); - $people = array_keys($this->contributions['people']); - array_unshift($people, self::UNDEFINED_PERSON); - $question = new ChoiceQuestion( - '[4/7] Who is making the the contribution?', - $people, - 0 + protected function getPerson() { + $people = $this->contributions['people']; + array_walk($people, fn(&$label, $key) => $label = sprintf('%s (%s)', $label, $key)); + $people = [self::UNDEFINED_PERSON_KEY => self::UNDEFINED_PERSON] + $people; + $person = select( + label: '[4/7] Person', + hint: 'Who is making the the contribution?', + options: $people, + default: self:: UNDEFINED_PERSON_KEY, + required: true, ); - $question->setErrorMessage('Person %s is invalid.'); - $person = $helper->ask($input, $output, $question); - if ($person == self::UNDEFINED_PERSON) { - $question = new Question('What is the name of the person? (E.g. José Saramago): '); - $question->setValidator([self::class, 'isNotEmpty']); - $name = $helper->ask($input, $output, $question); - $question = new Question('What is the identifier for the person? (E.g. Jose): '); - $question->setValidator([self::class, 'isNotEmpty']); - $machine_name = $helper->ask($input, $output, $question); + if ($person == self::UNDEFINED_PERSON_KEY) { + $not_empty = self::getNotEmptyClosure(); + $name = text( + label: 'Name', + placeholder: 'José Saramago', + hint: 'What is the name of the person?', + required: true, + validate: $not_empty + ); + $machine_name = text( + label: 'Identifier', + placeholder: 'jsaramago', + hint: 'What is the identifier for the person?', + required: true, + validate: $not_empty + ); $this->contributions['people'][$machine_name] = $name; $person = $machine_name; } @@ -273,9 +311,9 @@ public static function isNotEmpty($value) { */ public static function cleanEmpty($value) { $lines = explode(PHP_EOL, $value); - array_walk($lines, 'trim'); + array_walk($lines, fn(&$line) => $line = trim($line)); $lines = array_filter($lines); - return $lines; + return array_values($lines); } protected function generateMinimalYaml() { @@ -288,4 +326,13 @@ protected function generateMinimalYaml() { ]; } + /** + * Helper to get not empty validation closure. + */ + private static function getNotEmptyClosure(): \Closure { + return fn(string $value) => match (true) { + self::isNotEmpty($value) => 'Empty value', + default => null, + }; + } }