diff --git a/config/help/MakeStimulusController.txt b/config/help/MakeStimulusController.txt index 0785f9733..33e021376 100644 --- a/config/help/MakeStimulusController.txt +++ b/config/help/MakeStimulusController.txt @@ -1,5 +1,15 @@ -The %command.name% command generates new Stimulus Controller. +The %command.name% command generates a 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 +If the argument is missing, the command will ask for the controller name interactively. + +To generate a TypeScript file (instead of a JavaScript file) use the --typescript +(or --ts) option: + +php %command.full_name% hello --typescript + +It will also interactively ask for values, targets, classes to add to the Stimulus +controller (optional). + +php %command.full_name% diff --git a/src/Maker/MakeStimulusController.php b/src/Maker/MakeStimulusController.php index 0610c7d3b..103ffaa60 100644 --- a/src/Maker/MakeStimulusController.php +++ b/src/Maker/MakeStimulusController.php @@ -19,6 +19,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\Question; use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; @@ -44,8 +45,11 @@ public function configureCommand(Command $command, InputConfiguration $inputConf { $command ->addArgument('name', InputArgument::REQUIRED, 'The name of the Stimulus controller (e.g. hello)') + ->addOption('typescript', 'ts', InputOption::VALUE_NONE, 'Create a TypeScript controller (default is JavaScript)') ->setHelp($this->getHelpFileContents('MakeStimulusController.txt')) ; + + $inputConfig->setArgumentAsNonInteractive('typescript'); } public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void @@ -53,16 +57,22 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma $command->addArgument('extension', InputArgument::OPTIONAL); $command->addArgument('targets', InputArgument::OPTIONAL); $command->addArgument('values', InputArgument::OPTIONAL); + $command->addArgument('classes', InputArgument::OPTIONAL); + + if ($input->getOption('typescript')) { + $input->setArgument('extension', 'ts'); + } else { + $chosenExtension = $io->choice( + 'Language (JavaScript or TypeScript)', + [ + 'js' => 'JavaScript', + 'ts' => 'TypeScript', + ], + 'js', + ); - $chosenExtension = $io->choice( - 'Language (JavaScript or TypeScript)', - [ - 'js' => 'JavaScript', - 'ts' => 'TypeScript', - ] - ); - - $input->setArgument('extension', $chosenExtension); + $input->setArgument('extension', $chosenExtension); + } if ($io->confirm('Do you want to include targets?')) { $targets = []; @@ -98,16 +108,35 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma $input->setArgument('values', $values); } + + if ($io->confirm('Do you want to add classes?', false)) { + $classes = []; + $isFirstClass = true; + + while (true) { + $newClass = $this->askForNextClass($io, $classes, $isFirstClass); + if (null === $newClass) { + break; + } + + $isFirstClass = false; + $classes[] = $newClass; + } + + $input->setArgument('classes', $classes); + } } 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 = $targetArgs = $input->getArgument('targets') ?? []; + $values = $valuesArg = $input->getArgument('values') ?? []; + $classes = $classesArgs = $input->getArgument('classes') ?? []; $targets = empty($targets) ? $targets : \sprintf("['%s']", implode("', '", $targets)); + $classes = $classes ? \sprintf("['%s']", implode("', '", $classes)) : null; $fileName = \sprintf('%s_controller.%s', $controllerName, $chosenExtension); $filePath = \sprintf('assets/controllers/%s', $fileName); @@ -118,6 +147,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen [ 'targets' => $targets, 'values' => $values, + 'classes' => $classes, ] ); @@ -128,7 +158,12 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $io->text([ 'Next:', \sprintf('- Open %s and add the code you need', $filePath), - 'Find the documentation at https://github.com/symfony/stimulus-bridge', + '- Use the controller in your templates:', + ...array_map( + fn (string $line): string => " $line", + explode("\n", $this->generateUsageExample($controllerName, $targetArgs, $valuesArg, $classesArgs)), + ), + 'Find the documentation at https://symfony.com/bundles/StimulusBundle', ]); } @@ -215,6 +250,29 @@ private function askForNextValue(ConsoleStyle $io, array $values, bool $isFirstV return ['name' => $valueName, 'type' => $type]; } + /** @param string[] $classes */ + private function askForNextClass(ConsoleStyle $io, array $classes, bool $isFirstClass): ?string + { + $questionText = 'New class name (press to stop adding classes)'; + + if (!$isFirstClass) { + $questionText = 'Add another class? Enter the class name (or press to stop adding classes)'; + } + + $className = $io->ask($questionText, validator: function (?string $name) use ($classes) { + if (str_contains($name, ' ')) { + throw new \InvalidArgumentException('Class name cannot contain spaces.'); + } + if (\in_array($name, $classes, true)) { + throw new \InvalidArgumentException(\sprintf('The "%s" class already exists.', $name)); + } + + return $name; + }); + + return $className ?: null; + } + private function printAvailableTypes(ConsoleStyle $io): void { foreach ($this->getValuesTypes() as $type) { @@ -234,6 +292,51 @@ private function getValuesTypes(): array ]; } + /** + * @param array $targets + * @param array $values + * @param array $classes + */ + private function generateUsageExample(string $name, array $targets, array $values, array $classes): string + { + $slugify = fn (string $name) => str_replace('_', '-', Str::asSnakeCase($name)); + $controller = $slugify($name); + + $htmlTargets = []; + foreach ($targets as $target) { + $htmlTargets[] = \sprintf('
', $controller, $target); + } + + $htmlValues = []; + foreach ($values as ['name' => $name, 'type' => $type]) { + $value = match ($type) { + 'Array' => '[]', + 'Boolean' => 'false', + 'Number' => '123', + 'Object' => '{}', + 'String' => 'abc', + default => '', + }; + $htmlValues[] = \sprintf('data-%s-%s-value="%s"', $controller, $slugify($name), $value); + } + + $htmlClasses = []; + foreach ($classes as $class) { + $value = Str::asLowerCamelCase($class); + $htmlClasses[] = \sprintf('data-%s-%s-class="%s"', $controller, $slugify($class), $value); + } + + return \sprintf( + '
%s%s
', + $controller, + $htmlValues ? ("\n ".implode("\n ", $htmlValues)) : '', + $htmlClasses ? ("\n ".implode("\n ", $htmlClasses)) : '', + ($htmlValues || $htmlClasses) ? "\n" : '', + $htmlTargets ? ("\n ".implode("\n ", $htmlTargets)) : '', + "\n \n", + ); + } + public function configureDependencies(DependencyBuilder $dependencies): void { // lower than 8.1, allow WebpackEncoreBundle diff --git a/templates/stimulus/Controller.tpl.php b/templates/stimulus/Controller.tpl.php index 555e38da9..a506ee445 100644 --- a/templates/stimulus/Controller.tpl.php +++ b/templates/stimulus/Controller.tpl.php @@ -2,8 +2,9 @@ /* * The following line makes this controller "lazy": it won't be downloaded until needed -* See https://github.com/symfony/stimulus-bridge#lazy-controllers +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers */ + /* stimulusFetch: 'lazy' */ export default class extends Controller { @@ -14,5 +15,33 @@ } - // ... + + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } } diff --git a/tests/Maker/MakeStimulusControllerTest.php b/tests/Maker/MakeStimulusControllerTest.php index 69183d695..647864849 100644 --- a/tests/Maker/MakeStimulusControllerTest.php +++ b/tests/Maker/MakeStimulusControllerTest.php @@ -24,6 +24,19 @@ protected function getMakerClass(): string public function getTestDetails(): \Generator { + yield 'it_generates_stimulus_controller' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'default', // controller name + ], + ); + + $generatedFilePath = $runner->getPath('assets/controllers/default_controller.js'); + $this->assertFileExists($generatedFilePath); + }), + ]; + yield 'it_generates_stimulus_controller_with_targets' => [$this->createMakerTest() ->run(function (MakerTestRunner $runner) { $runner->runMaker( @@ -74,16 +87,192 @@ public function getTestDetails(): \Generator }), ]; - yield 'it_generates_typescript_stimulus_controller' => [$this->createMakerTest() + yield 'it_generates_stimulus_controller_with_values' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'with_values', // controller name + 'js', // controller language + 'no', // no targets + 'yes', // values + 'min', // first value + 'Number', // first value type + 'email', // second values + 'String', // second value type + '', // empty input to stop adding values + ]); + + $generatedFilePath = $runner->getPath('assets/controllers/with_values_controller.js'); + + $this->assertFileExists($generatedFilePath); + + $generatedFileContents = file_get_contents($generatedFilePath); + $expectedContents = file_get_contents(__DIR__.'/../fixtures/make-stimulus-controller/with_values.js'); + + $this->assertSame( + $expectedContents, + $generatedFileContents + ); + }), + ]; + + yield 'it_generates_stimulus_controller_with_classes' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'with_classes', // controller name + 'js', // use default extension (js) + 'no', // do not add targets + 'no', // do not add values + 'yes', // add classes + 'foo', // first class + 'bar', // second class + '', // empty input to stop adding classes + ]); + + $generatedFilePath = $runner->getPath('assets/controllers/with_classes_controller.js'); + + $this->assertFileExists($generatedFilePath); + + $generatedFileContents = file_get_contents($generatedFilePath); + $expectedContents = file_get_contents(__DIR__.'/../fixtures/make-stimulus-controller/with_classes.js'); + + $this->assertSame( + $expectedContents, + $generatedFileContents + ); + }), + ]; + + yield 'it_generates_stimulus_controller_with_targets_values_and_classes' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'with_targets_values_classes', + 'js', + 'yes', // add targets + 'aaa', + 'bbb', + '', // end + 'yes', // add values + 'ccc', + 'Number', + 'ddd', + 'String', + '', // end + 'yes', // add classes + 'eee', + 'fff', + '', // end + ]); + + $generatedFilePath = $runner->getPath('assets/controllers/with_targets_values_classes_controller.js'); + + $this->assertFileExists($generatedFilePath); + + $generatedFileContents = file_get_contents($generatedFilePath); + $expectedContents = file_get_contents(__DIR__.'/../fixtures/make-stimulus-controller/with_targets_values_classes.js'); + + $this->assertSame( + $expectedContents, + $generatedFileContents + ); + }), + ]; + + yield 'it_generates_typescript_stimulus_controller_interactively' => [$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')); + $this->assertFileDoesNotExist($runner->getPath('assets/controllers/typescript_controller.js')); + }), + ]; + + yield 'it_generates_typescript_stimulus_controller_when_option_is_set' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'typescript', // controller name + // '', // language is not asked interactively + 'no', // do not add targets + ], + ' --typescript' + ); $this->assertFileExists($runner->getPath('assets/controllers/typescript_controller.ts')); + $this->assertFileDoesNotExist($runner->getPath('assets/controllers/typescript_controller.js')); + }), + ]; + + yield 'it_displays_controller_basic_usage_example' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker( + [ + 'fooBar', + 'js', + ], + ); + + $usageExample = << + + + HTML; + + $this->assertStringContainsString('- Use the controller in your templates:', $output); + foreach (explode("\n", $usageExample) as $line) { + $this->assertStringContainsString($line, $output); + } + }), + ]; + + yield 'it_displays_controller_complete_usage_example' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker( + [ + 'fooBar', + 'js', + 'yes', // add targets + 'firstOne', + 'secondOne', + '', + 'yes', // add values + 'minItems', + 'Number', + 'email', + 'String', + '', + 'yes', // add classes + 'isVisible', + 'hidden', + '', + ], + ); + + $usageExample = << +
+
+ + + HTML; + + $this->assertStringContainsString('- Use the controller in your templates:', $output); + foreach (explode("\n", $usageExample) as $line) { + $this->assertStringContainsString($line, $output); + } }), ]; } diff --git a/tests/fixtures/make-stimulus-controller/with_classes.js b/tests/fixtures/make-stimulus-controller/with_classes.js new file mode 100644 index 000000000..7fd71e0bc --- /dev/null +++ b/tests/fixtures/make-stimulus-controller/with_classes.js @@ -0,0 +1,39 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers +*/ + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static classes = ['foo', 'bar'] + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } +} diff --git a/tests/fixtures/make-stimulus-controller/with_targets.js b/tests/fixtures/make-stimulus-controller/with_targets.js index d78f58f25..6c80bb339 100644 --- a/tests/fixtures/make-stimulus-controller/with_targets.js +++ b/tests/fixtures/make-stimulus-controller/with_targets.js @@ -2,10 +2,38 @@ 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 +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers */ + /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['results', 'messages', 'errors'] - // ... + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } } diff --git a/tests/fixtures/make-stimulus-controller/with_targets_values_classes.js b/tests/fixtures/make-stimulus-controller/with_targets_values_classes.js new file mode 100644 index 000000000..5e2d9170c --- /dev/null +++ b/tests/fixtures/make-stimulus-controller/with_targets_values_classes.js @@ -0,0 +1,44 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers +*/ + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['aaa', 'bbb'] + static values = { + ccc: Number, + ddd: String, + } + static classes = ['eee', 'fff'] + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } +} diff --git a/tests/fixtures/make-stimulus-controller/with_values.js b/tests/fixtures/make-stimulus-controller/with_values.js new file mode 100644 index 000000000..a5c1c2561 --- /dev/null +++ b/tests/fixtures/make-stimulus-controller/with_values.js @@ -0,0 +1,42 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers +*/ + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static values = { + min: Number, + email: String, + } + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } +} diff --git a/tests/fixtures/make-stimulus-controller/without_targets.js b/tests/fixtures/make-stimulus-controller/without_targets.js index c07f69be3..2a74ae7d7 100644 --- a/tests/fixtures/make-stimulus-controller/without_targets.js +++ b/tests/fixtures/make-stimulus-controller/without_targets.js @@ -2,9 +2,37 @@ 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 +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers */ + /* stimulusFetch: 'lazy' */ export default class extends Controller { - // ... + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } }