diff --git a/src/Maker/MakeStimulusController.php b/src/Maker/MakeStimulusController.php new file mode 100644 index 000000000..2e15d3858 --- /dev/null +++ b/src/Maker/MakeStimulusController.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Str; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; + +/** + * @author Abdelilah Jabri + * + * @internal + */ +final class MakeStimulusController extends AbstractMaker +{ + public static function getCommandName(): string + { + return 'make:stimulus-controller'; + } + + public static function getCommandDescription(): string + { + return 'Creates a new Stimulus controller'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument('name', InputArgument::REQUIRED, 'The name of the Stimulus controller (e.g. hello)') + ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeStimulusController.txt')); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + $command->addArgument('extension', InputArgument::OPTIONAL); + $command->addArgument('targets', InputArgument::OPTIONAL, '', []); + $command->addArgument('values', InputArgument::OPTIONAL, '', []); + + $chosenExtension = $io->choice( + 'Language (JavaScript or TypeScript)', + [ + 'js' => 'JavaScript', + 'ts' => 'TypeScript', + ] + ); + + $input->setArgument('extension', $chosenExtension); + + if ($io->confirm('Do you want to include targets?')) { + $targets = []; + $isFirstTarget = true; + + while (true) { + $newTarget = $this->askForNextTarget($io, $targets, $isFirstTarget); + $isFirstTarget = false; + + if (null === $newTarget) { + break; + } + + $targets[] = $newTarget; + } + + $input->setArgument('targets', $targets); + } + + if ($io->confirm('Do you want to include values?')) { + $values = []; + $isFirstValue = true; + while (true) { + $newValue = $this->askForNextValue($io, $values, $isFirstValue); + $isFirstValue = false; + + if (null === $newValue) { + break; + } + + $values[$newValue['name']] = $newValue; + } + + $input->setArgument('values', $values); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $controllerName = Str::asSnakeCase($input->getArgument('name')); + $chosenExtension = $input->getArgument('extension'); + $targets = $input->getArgument('targets'); + $values = $input->getArgument('values'); + + $targets = empty($targets) ? $targets : sprintf("['%s']", implode("', '", $targets)); + + $fileName = sprintf('%s_controller.%s', $controllerName, $chosenExtension); + $filePath = sprintf('assets/controllers/%s', $fileName); + + $generator->generateFile( + $filePath, + 'stimulus/Controller.tpl.php', + [ + 'targets' => $targets, + 'values' => $values, + ] + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + + $io->text([ + 'Next:', + sprintf('- Open %s and add the code you need', $filePath), + 'Find the documentation at https://github.com/symfony/stimulus-bridge', + ]); + } + + private function askForNextTarget(ConsoleStyle $io, array $targets, bool $isFirstTarget): ?string + { + $questionText = 'New target name (press to stop adding targets)'; + + if (!$isFirstTarget) { + $questionText = 'Add another target? Enter the target name (or press to stop adding targets)'; + } + + $targetName = $io->ask($questionText, null, function (?string $name) use ($targets) { + if (\in_array($name, $targets)) { + throw new \InvalidArgumentException(sprintf('The "%s" target already exists.', $name)); + } + + return $name; + }); + + return !$targetName ? null : $targetName; + } + + private function askForNextValue(ConsoleStyle $io, array $values, bool $isFirstValue): ?array + { + $questionText = 'New value name (press to stop adding values)'; + + if (!$isFirstValue) { + $questionText = 'Add another value? Enter the value name (or press to stop adding values)'; + } + + $valueName = $io->ask($questionText, null, function ($name) use ($values) { + if (\array_key_exists($name, $values)) { + throw new \InvalidArgumentException(sprintf('The "%s" value already exists.', $name)); + } + + return $name; + }); + + if (!$valueName) { + return null; + } + + $defaultType = 'String'; + // try to guess the type by the value name prefix/suffix + // convert to snake case for simplicity + $snakeCasedField = Str::asSnakeCase($valueName); + + if ('_id' === $suffix = substr($snakeCasedField, -3)) { + $defaultType = 'Number'; + } elseif (0 === strpos($snakeCasedField, 'is_')) { + $defaultType = 'Boolean'; + } elseif (0 === strpos($snakeCasedField, 'has_')) { + $defaultType = 'Boolean'; + } + + $type = null; + $types = $this->getValuesTypes(); + + while (null === $type) { + $question = new Question('Value type (enter ? to see all types)', $defaultType); + $question->setAutocompleterValues($types); + $type = $io->askQuestion($question); + + if ('?' === $type) { + $this->printAvailableTypes($io); + $io->writeln(''); + + $type = null; + } elseif (!\in_array($type, $types)) { + $this->printAvailableTypes($io); + $io->error(sprintf('Invalid type "%s".', $type)); + $io->writeln(''); + + $type = null; + } + } + + return ['name' => $valueName, 'type' => $type]; + } + + private function printAvailableTypes(ConsoleStyle $io): void + { + foreach ($this->getValuesTypes() as $type) { + $io->writeln(sprintf('%s', $type)); + } + } + + private function getValuesTypes(): array + { + return [ + 'Array', + 'Boolean', + 'Number', + 'Object', + 'String', + ]; + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency( + WebpackEncoreBundle::class, + 'webpack-encore-bundle' + ); + } +} diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 624a230e3..dba2fce4a 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -129,5 +129,9 @@ %kernel.project_dir% + + + + diff --git a/src/Resources/help/MakeStimulusController.txt b/src/Resources/help/MakeStimulusController.txt new file mode 100644 index 000000000..0785f9733 --- /dev/null +++ b/src/Resources/help/MakeStimulusController.txt @@ -0,0 +1,5 @@ +The %command.name% command generates new Stimulus Controller. + +php %command.full_name% hello + +If the argument is missing, the command will ask for the controller name interactively. \ No newline at end of file diff --git a/src/Resources/skeleton/stimulus/Controller.tpl.php b/src/Resources/skeleton/stimulus/Controller.tpl.php new file mode 100644 index 000000000..555e38da9 --- /dev/null +++ b/src/Resources/skeleton/stimulus/Controller.tpl.php @@ -0,0 +1,18 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://github.com/symfony/stimulus-bridge#lazy-controllers +*/ +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + + static values = { + + : , + + } + + // ... +} diff --git a/tests/Maker/MakeStimulusControllerTest.php b/tests/Maker/MakeStimulusControllerTest.php new file mode 100644 index 000000000..69183d695 --- /dev/null +++ b/tests/Maker/MakeStimulusControllerTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker; + +use Symfony\Bundle\MakerBundle\Maker\MakeStimulusController; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; + +class MakeStimulusControllerTest extends MakerTestCase +{ + protected function getMakerClass(): string + { + return MakeStimulusController::class; + } + + public function getTestDetails(): \Generator + { + yield 'it_generates_stimulus_controller_with_targets' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'with_targets', // controller name + 'js', // controller language + 'yes', // add targets + 'results', // first target + 'messages', // second target + 'errors', // third target + '', // empty input to stop adding targets + ]); + + $generatedFilePath = $runner->getPath('assets/controllers/with_targets_controller.js'); + + $this->assertFileExists($generatedFilePath); + + $generatedFileContents = file_get_contents($generatedFilePath); + $expectedContents = file_get_contents(__DIR__.'/../fixtures/make-stimulus-controller/with_targets.js'); + + $this->assertSame( + $expectedContents, + $generatedFileContents + ); + }), + ]; + + yield 'it_generates_stimulus_controller_without_targets' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'without_targets', // controller name + 'js', // controller language + 'no', // do not add targets + ]); + + $generatedFilePath = $runner->getPath('assets/controllers/without_targets_controller.js'); + + $this->assertFileExists($generatedFilePath); + + $generatedFileContents = file_get_contents($generatedFilePath); + $expectedContents = file_get_contents(__DIR__.'/../fixtures/make-stimulus-controller/without_targets.js'); + + $this->assertSame( + $expectedContents, + $generatedFileContents + ); + }), + ]; + + yield 'it_generates_typescript_stimulus_controller' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'typescript', // controller name + 'ts', // controller language + 'no', // do not add targets + ]); + + $this->assertFileExists($runner->getPath('assets/controllers/typescript_controller.ts')); + }), + ]; + } +} diff --git a/tests/fixtures/make-stimulus-controller/with_targets.js b/tests/fixtures/make-stimulus-controller/with_targets.js new file mode 100644 index 000000000..d78f58f25 --- /dev/null +++ b/tests/fixtures/make-stimulus-controller/with_targets.js @@ -0,0 +1,11 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://github.com/symfony/stimulus-bridge#lazy-controllers +*/ +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['results', 'messages', 'errors'] + // ... +} diff --git a/tests/fixtures/make-stimulus-controller/without_targets.js b/tests/fixtures/make-stimulus-controller/without_targets.js new file mode 100644 index 000000000..c07f69be3 --- /dev/null +++ b/tests/fixtures/make-stimulus-controller/without_targets.js @@ -0,0 +1,10 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://github.com/symfony/stimulus-bridge#lazy-controllers +*/ +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + // ... +}