Skip to content

Commit

Permalink
[make:stimulus-controller] Add classes support, generate usage code…
Browse files Browse the repository at this point in the history
…, fix doc, add tests (#1631)

* Replace StimulusBridge with StimulusBundle in docblock

* Add methods initialize, connect and disconnect with code comment

* Add line break before "stimulusFetch: lazy" to emphasis line and help UX parsers

* Update tests for lazy doc changes

* Update tests for empty line before lazy

* Update tests with base methods

* Use 'JavaScript' as default extension

* Add `--typescript` / `--ts` (non interactive) option (default false)

* Update Maker documentation link displayed after code generation

* Define classes interactively

* Generate an example usage

* phpstan happiness
  • Loading branch information
smnandre authored Jan 24, 2025
1 parent 468ff27 commit 881ecf0
Show file tree
Hide file tree
Showing 9 changed files with 534 additions and 22 deletions.
14 changes: 12 additions & 2 deletions config/help/MakeStimulusController.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
The <info>%command.name%</info> command generates new Stimulus Controller.
The <info>%command.name%</info> command generates a new Stimulus controller.

<info>php %command.full_name% hello</info>

If the argument is missing, the command will ask for the controller name interactively.
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 <info>--typescript</info>
(or <info>--ts</info>) option:

<info>php %command.full_name% hello --typescript</info>

It will also interactively ask for values, targets, classes to add to the Stimulus
controller (optional).

<info>php %command.full_name%</info>
127 changes: 115 additions & 12 deletions src/Maker/MakeStimulusController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,25 +45,34 @@ public function configureCommand(Command $command, InputConfiguration $inputConf
{
$command
->addArgument('name', InputArgument::REQUIRED, 'The name of the Stimulus controller (e.g. <fg=yellow>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
{
$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 (<fg=yellow>JavaScript</> or <fg=yellow>TypeScript</>)',
[
'js' => 'JavaScript',
'ts' => 'TypeScript',
],
'js',
);

$chosenExtension = $io->choice(
'Language (<fg=yellow>JavaScript</> or <fg=yellow>TypeScript</>)',
[
'js' => 'JavaScript',
'ts' => 'TypeScript',
]
);

$input->setArgument('extension', $chosenExtension);
$input->setArgument('extension', $chosenExtension);
}

if ($io->confirm('Do you want to include targets?')) {
$targets = [];
Expand Down Expand Up @@ -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);
Expand All @@ -118,6 +147,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
[
'targets' => $targets,
'values' => $values,
'classes' => $classes,
]
);

Expand All @@ -128,7 +158,12 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
$io->text([
'Next:',
\sprintf('- Open <info>%s</info> and add the code you need', $filePath),
'Find the documentation at <fg=yellow>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 <fg=yellow>https://symfony.com/bundles/StimulusBundle</>',
]);
}

Expand Down Expand Up @@ -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 <return> to stop adding classes)';

if (!$isFirstClass) {
$questionText = 'Add another class? Enter the class name (or press <return> 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) {
Expand All @@ -234,6 +292,51 @@ private function getValuesTypes(): array
];
}

/**
* @param array<int, string> $targets
* @param array<array{name: string, type: string}> $values
* @param array<int, string> $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('<div data-%s-target="%s"></div>', $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(
'<div data-controller="%s"%s%s%s>%s%s</div>',
$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
Expand Down
33 changes: 31 additions & 2 deletions templates/stimulus/Controller.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
<?= $targets ? " static targets = $targets\n" : "" ?>
Expand All @@ -14,5 +15,33 @@
<?php endforeach; ?>
}
<?php } ?>
// ...
<?= $classes ? " static classes = $classes\n" : '' ?>

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)
}
}
Loading

0 comments on commit 881ecf0

Please sign in to comment.