From 9d2108b73cd1f3d9e010cf78b44708ab5b7e1916 Mon Sep 17 00:00:00 2001 From: Steven Renaux Date: Fri, 1 Sep 2023 11:17:32 +0200 Subject: [PATCH] Add `debug:component` command --- src/LiveComponent/doc/index.rst | 7 + src/TwigComponent/CHANGELOG.md | 1 + src/TwigComponent/composer.json | 1 + src/TwigComponent/doc/index.rst | 59 ++++ .../src/Command/ComponentDebugCommand.php | 273 ++++++++++++++++++ .../TwigComponentExtension.php | 14 + .../Fixtures/Component/OtherDirectory.php | 54 ++++ .../templates/bar/OtherDirectory.html.twig | 9 + .../Command/ComponentDebugCommandTest.php | 143 +++++++++ 9 files changed, 561 insertions(+) create mode 100644 src/TwigComponent/src/Command/ComponentDebugCommand.php create mode 100644 src/TwigComponent/tests/Fixtures/Component/OtherDirectory.php create mode 100644 src/TwigComponent/tests/Fixtures/templates/bar/OtherDirectory.html.twig create mode 100644 src/TwigComponent/tests/Unit/Command/ComponentDebugCommandTest.php diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index fe1c4c7651a..3a258767741 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -3145,6 +3145,12 @@ the change of one specific key: } } +Debugging Components +-------------------- + +Need to list or debug some component issues. +The `Twig Component debug command`_ can help you. + Test Helper ----------- @@ -3254,3 +3260,4 @@ bound to Symfony's BC policy for the moment. .. _`How to Work with Form Themes`: https://symfony.com/doc/current/form/form_themes.html .. _`Symfony's built-in form theming techniques`: https://symfony.com/doc/current/form/form_themes.html .. _`pass content to Twig Components`: https://symfony.com/bundles/ux-twig-component/current/index.html#passing-blocks +.. _`Twig Component debug command`: https://symfony.com/bundles/ux-twig-component/current/index.html#debugging-components diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 0633b5ab72a..4fa8e1eb812 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -7,6 +7,7 @@ - Add `RenderedComponent::crawler()` and `toString()` methods. - Allow a block outside a Twig component to be available inside via `outerBlocks`. - Fix `` syntax where an attribute is set to an empty value. +- Add component debug command for TwigComponent and LiveComponent. ## 2.9.0 diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json index 29e303f329d..7dfd6d11f99 100644 --- a/src/TwigComponent/composer.json +++ b/src/TwigComponent/composer.json @@ -34,6 +34,7 @@ "twig/twig": "^2.14.7|^3.0.4" }, "require-dev": { + "symfony/console": "^5.4|^6.0", "symfony/css-selector": "^5.4|^6.0", "symfony/dom-crawler": "^5.4|^6.0", "symfony/framework-bundle": "^5.4|^6.0", diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 21f036a49b9..fcd547bffd4 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -1114,6 +1114,65 @@ To tell the system that ``icon`` and ``type`` are props and not attributes, use {% endif %} +Debugging Components +-------------------- + +As your application grows, you'll eventually have a lot of components. +This command will help you to debug some components issues. +First, the debug:twig-component command lists all your application components +who live in ``templates/components``: + +.. code-block:: terminal + + $ php bin/console debug:component + + +---------------+-----------------------------+------------------------------------+------+ + | Component | Class | Template | Live | + +---------------+-----------------------------+------------------------------------+------+ + | Coucou | App\Components\Alert | components/Coucou.html.twig | | + | RandomNumber | App\Components\RandomNumber | components/RandomNumber.html.twig | X | + | Test | App\Components\foo\Test | components/foo/Test.html.twig | | + | Button | Anonymous component | components/Button.html.twig | | + | foo:Anonymous | Anonymous component | components/foo/Anonymous.html.twig | | + +---------------+-----------------------------+------------------------------------+------+ + +.. tip:: + + The Live column show you which component is a LiveComponent. + +If you have some components who doesn't live in ``templates/components``, +but in ``templates/bar`` for example you can pass an option: + +.. code-block:: terminal + + $ php bin/console debug:twig-component --dir=bar + + +----------------+-------------------------------+------------------------------+------+ + | Component | Class | Template | Live | + +----------------+-------------------------------+------------------------------+------+ + | OtherDirectory | App\Components\OtherDirectory | bar/OtherDirectory.html.twig | | + +----------------+-------------------------------+------------------------------+------+ + +And the name of some component to this argument to print the +component details: + +.. code-block:: terminal + + $ php bin/console debug:component RandomNumber + + +---------------------------------------------------+-----------------------------------+ + | Property | Value | + +---------------------------------------------------+-----------------------------------+ + | Component | RandomNumber | + | Live | X | + | Class | App\Components\RandomNumber | + | Template | components/RandomNumber.html.twig | + | Properties (type / name / default value if exist) | string $name = toto | + | | string $type = test | + | Live Properties | int $max = 1000 | + | | int $min = 10 | + +---------------------------------------------------+-----------------------------------+ + Test Helpers ------------ diff --git a/src/TwigComponent/src/Command/ComponentDebugCommand.php b/src/TwigComponent/src/Command/ComponentDebugCommand.php new file mode 100644 index 00000000000..a23a27fe5b6 --- /dev/null +++ b/src/TwigComponent/src/Command/ComponentDebugCommand.php @@ -0,0 +1,273 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\TwigComponent\Attribute\PostMount; +use Symfony\UX\TwigComponent\Attribute\PreMount; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentMetadata; +use Symfony\UX\TwigComponent\Twig\PropsNode; +use Twig\Environment; + +#[AsCommand(name: 'debug:twig-component', description: 'Display current components and them usages for an application')] +class ComponentDebugCommand extends Command +{ + public function __construct(private string $twigTemplatesPath, private ComponentFactory $componentFactory, private Environment $twigEnvironment, private iterable $components) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputArgument('name', InputArgument::OPTIONAL, 'A component name'), + new InputOption('dir', null, InputOption::VALUE_REQUIRED, 'Show all components with a specific directory in templates', 'components'), + ]) + ->setHelp(<<<'EOF' + The %command.name% display all components in your application: + + php %command.full_name% + + Find all components within a specific directory in templates by specifying the directory name with the --dir option: + + php %command.full_name% --dir=bar/foo + + EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + $componentsDir = $input->getOption('dir'); + + if (null !== $name) { + try { + $metadata = $this->componentFactory->metadataFor($name); + } catch (\Exception $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $class = $metadata->get('class'); + $live = null; + $allProperties = []; + + if ($class) { + if ($metadata->get('live')) { + $live = 'X'; + } + + $reflectionClass = new \ReflectionClass($class); + $properties = $reflectionClass->getProperties(); + $allLiveProperties = []; + + foreach ($properties as $property) { + if ($property->isPublic()) { + $visibility = $property->getType()?->getName(); + $propertyName = $property->getName(); + $value = $property->getDefaultValue(); + $propertyAttributes = $property->getAttributes(LiveProp::class); + + $propertyDisplay = $visibility.' $'.$propertyName.(null !== $value ? ' = '.$value : ''); + + if (\count($propertyAttributes) > 0) { + $allLiveProperties[] = $propertyDisplay; + } else { + $allProperties[] = $propertyDisplay; + } + } + } + + $methods = $reflectionClass->getMethods(); + $allEvents = []; + $allActions = []; + + foreach ($methods as $method) { + if ('mount' === $method->getName()) { + $allEvents[] = 'Mount'; + } + + foreach ($method->getAttributes() as $attribute) { + if (PreMount::class === $attribute->getName()) { + $allEvents[] = 'PreMount'; + break; + } + + if (PostMount::class === $attribute->getName()) { + $allEvents[] = 'PostMount'; + break; + } + + if (LiveAction::class === $attribute->getName()) { + $allActions[] = $method->getName(); + break; + } + } + } + } else { + $allProperties = $this->getPropertiesForAnonymousComponent($metadata); + } + + $componentInfos = [ + ['Component', $name], + ['Live', $live], + ['Class', $class ?? 'Anonymous component'], + ['Template', $metadata->getTemplate()], + ['Properties', \count($allProperties) > 0 ? implode("\n", $allProperties) : null], + ]; + + if (isset($allLiveProperties) && \count($allLiveProperties) > 0) { + $componentInfos[] = ['Live Properties', implode("\n", $allLiveProperties)]; + } + if (isset($allEvents) && \count($allEvents) > 0) { + $componentInfos[] = ['Events', implode("\n", $allEvents)]; + } + if (isset($allActions) && \count($allActions) > 0) { + $componentInfos[] = ['LiveAction Methods', implode("\n", $allActions)]; + } + + $table = new Table($output); + $table->setHeaders(['Property', 'Value'])->setRows($componentInfos); + $table->render(); + + return Command::SUCCESS; + } + + $finderTemplates = new Finder(); + $finderTemplates->files()->in("{$this->twigTemplatesPath}/components"); + + $anonymousTemplatesComponents = []; + foreach ($finderTemplates as $template) { + $anonymousTemplatesComponents[] = $template->getRelativePathname(); + } + + $componentsWithClass = []; + foreach ($this->components as $class) { + $reflectionClass = new \ReflectionClass($class); + $attributes = $reflectionClass->getAttributes(); + + foreach ($attributes as $attribute) { + $arguments = $attribute->getArguments(); + + $name = $arguments['name'] ?? $arguments[0] ?? null; + $template = $arguments['template'] ?? $arguments[1] ?? null; + + if (null !== $template || null !== $name) { + if (null !== $template && null !== $name) { + $templateFile = str_replace('components/', '', $template); + $metadata = $this->componentFactory->metadataFor($name); + } elseif (null !== $name) { + $templateFile = str_replace(':', '/', "{$name}.html.twig"); + $metadata = $this->componentFactory->metadataFor($name); + } else { + $templateFile = str_replace('components/', '', $template); + $metadata = $this->componentFactory->metadataFor(str_replace('.html.twig', '', $templateFile)); + } + } else { + $templateFile = "{$reflectionClass->getShortName()}.html.twig"; + $metadata = $this->componentFactory->metadataFor($reflectionClass->getShortName()); + } + + $componentsWithClass[] = [ + 'name' => $metadata->getName(), + 'live' => null !== $metadata->get('live') ? 'X' : null, + ]; + + if (($key = array_search($templateFile, $anonymousTemplatesComponents)) !== false) { + unset($anonymousTemplatesComponents[$key]); + } + } + } + + $anonymousComponents = array_map(fn ($template): array => [ + 'name' => str_replace('/', ':', str_replace('.html.twig', '', $template)), + 'live' => null, + ], $anonymousTemplatesComponents); + + $allComponents = array_merge($componentsWithClass, $anonymousComponents); + $dataToRender = []; + foreach ($allComponents as $component) { + $metadata = $this->componentFactory->metadataFor($component['name']); + + if (str_contains($metadata->getTemplate(), $componentsDir)) { + $dataToRender[] = [ + $metadata->getName(), + $metadata->get('class') ?? 'Anonymous component', + $metadata->getTemplate(), + $component['live'], + ]; + } + } + + $table = new Table($output); + $table->setHeaders(['Component', 'Class', 'Template', 'Live'])->setRows($dataToRender); + $table->render(); + + return Command::SUCCESS; + } + + private function getPropertiesForAnonymousComponent(ComponentMetadata $metadata): array + { + $allProperties = []; + + $source = $this->twigEnvironment->load($metadata->getTemplate())->getSourceContext(); + $tokenStream = $this->twigEnvironment->tokenize($source); + $bodyNode = $this->twigEnvironment->parse($tokenStream)->getNode('body')->getNode(0); + + $propsNode = []; + + foreach ($bodyNode as $node) { + if ($node instanceof PropsNode) { + $propsNode = $node; + break; + } + } + + if (\count($propsNode) > 0) { + $allVariables = $propsNode->getAttribute('names'); + + foreach ($allVariables as $variable) { + if ($propsNode->hasNode($variable)) { + $value = $propsNode->getNode($variable)->getAttribute('value'); + + if (\is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + $property = $variable.' = '.$value; + } else { + $property = $variable; + } + + $allProperties[] = $property; + } + } + + return $allProperties; + } +} diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 59b9789fe64..a01a76bacdd 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -16,8 +16,10 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Parameter; use Symfony\Component\DependencyInjection\Reference; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Symfony\UX\TwigComponent\Command\ComponentDebugCommand; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; use Symfony\UX\TwigComponent\ComponentRendererInterface; @@ -28,6 +30,8 @@ use Symfony\UX\TwigComponent\Twig\ComponentLexer; use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; + /** * @author Kevin Bond * @@ -91,5 +95,15 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in % $container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class) ->setDecoratedService(new Reference('twig.configurator.environment')) ->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]); + + $container->register('console.command.stimulus_component_debug', ComponentDebugCommand::class) + ->setArguments([ + new Parameter('twig.default_path'), + new Reference('ux.twig_component.component_factory'), + new Reference('twig'), + tagged_iterator('twig.component'), + ]) + ->addTag('console.command') + ; } } diff --git a/src/TwigComponent/tests/Fixtures/Component/OtherDirectory.php b/src/TwigComponent/tests/Fixtures/Component/OtherDirectory.php new file mode 100644 index 00000000000..087390a34b2 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/OtherDirectory.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component; + +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +#[AsTwigComponent(name: 'OtherDirectory', template: 'bar/OtherDirectory.html.twig')] +class OtherDirectory +{ + public string $type = 'success'; + public string $message; + + public function mount(bool $isSuccess = true) + { + $this->type = $isSuccess ? 'success' : 'danger'; + } + + #[PreMount] + public function preMount(array $data): array + { + // validate data + $resolver = new OptionsResolver(); + $resolver->setDefaults(['type' => 'success']); + $resolver->setAllowedValues('type', ['success', 'danger']); + $resolver->setRequired('message'); + $resolver->setAllowedTypes('message', 'string'); + + return $resolver->resolve($data); + } + + public function getIconClass(): string + { + return match ($this->type) { + 'success' => 'fa fa-circle-check', + 'danger' => 'fa fa-circle-exclamation', + }; + } + + public function getPackageCount(): int + { + return 10; + } +} diff --git a/src/TwigComponent/tests/Fixtures/templates/bar/OtherDirectory.html.twig b/src/TwigComponent/tests/Fixtures/templates/bar/OtherDirectory.html.twig new file mode 100644 index 00000000000..3a5f7d1e694 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/bar/OtherDirectory.html.twig @@ -0,0 +1,9 @@ + diff --git a/src/TwigComponent/tests/Unit/Command/ComponentDebugCommandTest.php b/src/TwigComponent/tests/Unit/Command/ComponentDebugCommandTest.php new file mode 100644 index 00000000000..ea1093cecb6 --- /dev/null +++ b/src/TwigComponent/tests/Unit/Command/ComponentDebugCommandTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Unit; + +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Console\Tester\CommandTester; + +class ComponentDebugCommandTest extends KernelTestCase +{ + public function testWithNoComponent(): void + { + $commandTester = $this->createCommandTester(); + $commandTester->execute([]); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('Component', $display); + $this->assertStringContainsString('Class', $display); + $this->assertStringContainsString('Template', $display); + $this->assertStringContainsString('Live', $display); + } + + public function testWithNoMatchComponent(): void + { + $commandTester = $this->createCommandTester(); + $result = $commandTester->execute(['name' => 'NoMatchComponent']); + + $this->assertEquals(1, $result); + $this->assertStringContainsString('Unknown component "NoMatchComponent".', $commandTester->getDisplay()); + } + + public function testComponentWithClass(): void + { + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'BasicComponent']); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->tableDisplayCheck($display); + $this->assertStringContainsString('BasicComponent', $display); + $this->assertStringContainsString('Component\BasicComponent', $display); + $this->assertStringContainsString('components/BasicComponent.html.twig', $display); + } + + public function testComponentWithClassPropertiesAndCustomName(): void + { + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'component_c']); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->tableDisplayCheck($display); + $this->assertStringContainsString('component_c', $display); + $this->assertStringContainsString('Component\ComponentC', $display); + $this->assertStringContainsString('components/component_c.html.twig', $display); + $this->assertStringContainsString('$propA', $display); + $this->assertStringContainsString('$propB', $display); + $this->assertStringContainsString('$propC', $display); + $this->assertStringContainsString('Mount', $display); + } + + public function testComponentWithClassPropertiesCustomNameAndCustomTemplate(): void + { + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'component_b']); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->tableDisplayCheck($display); + $this->assertStringContainsString('component_b', $display); + $this->assertStringContainsString('Component\ComponentB', $display); + $this->assertStringContainsString('components/custom1.html.twig', $display); + $this->assertStringContainsString('string $value', $display); + $this->assertStringContainsString('string $postValue', $display); + } + + public function testWithAnonymousComponent(): void + { + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'Button']); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->tableDisplayCheck($display); + $this->assertStringContainsString('Button', $display); + $this->assertStringContainsString('Anonymous component', $display); + $this->assertStringContainsString('components/Button.html.twig', $display); + $this->assertStringContainsString('label', $display); + $this->assertStringContainsString('primary = true', $display); + } + + public function testWithDirectoryOption() + { + $commandTester = $this->createCommandTester(); + $commandTester->execute(['--dir' => 'bar']); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('foo:bar:baz', $display); + $this->assertStringContainsString('OtherDirectory', $display); + $this->assertStringContainsString('components/foo/bar/baz.html.twig', $display); + $this->assertStringContainsString('bar/OtherDirectory.html.twig', $display); + } + + private function createCommandTester(): CommandTester + { + $kernel = self::bootKernel(); + $application = new Application($kernel); + + return new CommandTester($application->find('debug:twig-component')); + } + + private function tableDisplayCheck(string $display): void + { + $this->assertStringContainsString('Component', $display); + $this->assertStringContainsString('Live', $display); + $this->assertStringContainsString('Class', $display); + $this->assertStringContainsString('Template', $display); + $this->assertStringContainsString('Properties', $display); + } +}