[LiveComponent] Add debug:live-component command

This commit is contained in:
Mickaël BULIARD
2026-03-05 17:56:51 +01:00
committed by Hugo Alliaume
parent 1d06dbc4d6
commit 8d1df497b6
9 changed files with 501 additions and 3 deletions

View File

@@ -23,6 +23,7 @@
```
- Add support for dynamic template resolution with `AsLiveComponent(template: FromMethod('customFunction'))`
- Add `debug:live-component` command
## 2.31

View File

@@ -0,0 +1,254 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\Command;
use Symfony\Component\Console\Attribute\AsCommand;
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\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\TypeInfo\Type;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
#[AsCommand(name: 'debug:live-component', description: 'Display live components and their usage for an application')]
class LiveComponentDebugCommand extends Command
{
public function __construct(
protected readonly LiveComponentMetadataFactory $metadataFactory,
protected readonly array $liveComponentList,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument(
'name',
InputArgument::OPTIONAL,
'A LiveComponent name or part of the name'
),
new InputOption(
name: 'listening',
mode: InputOption::VALUE_REQUIRED,
description: 'Filter list to display only those listening to the given event'
),
])
->setHelp(
<<<'EOF'
The <info>%command.name%</info> display all the live components in your application.
To list all live components:
<info>php %command.full_name%</info>
To get specific information about a component, specify its name (or a part of it):
<info>php %command.full_name% ProductSearch</info>
EOF
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('name');
if (\is_string($name)) {
$componentName = $this->findComponentName($io, $name, $input->isInteractive());
if (null === $componentName) {
$io->error(\sprintf('Unknown component "%s".', $name));
return Command::FAILURE;
}
$this->displayComponentDetails($io, $componentName);
return Command::SUCCESS;
}
$components = $this->findComponents($input->getOption('listening'));
$this->displayComponentsTable($components, $io);
return Command::SUCCESS;
}
private function findComponentName(SymfonyStyle $io, string $name, bool $interactive): ?string
{
$components = [];
foreach ($this->liveComponentList as $className) {
$metadata = $this->metadataFactory->getMetadata($className);
$componentName = $metadata->getComponentMetadata()->getName();
if ($name === $componentName) {
return $name;
}
if (str_contains($componentName, $name)) {
$components[$componentName] = $componentName;
}
}
if ($interactive && \count($components)) {
return $io->choice('Select one of the following component to display its information', array_values($components), 0);
}
return null;
}
/**
* @return array<string, LiveComponentMetadata>
*/
private function findComponents(?string $eventFilter = null): array
{
$components = [];
if (null === $eventFilter) {
foreach ($this->liveComponentList as $className) {
$components[$className] ??= $this->metadataFactory->getMetadata($className);
}
return $components;
}
foreach ($this->liveComponentList as $className) {
foreach (AsLiveComponent::liveListeners($className) as $listener) {
if ($listener['event'] === $eventFilter) {
$components[$className] ??= $this->metadataFactory->getMetadata($className);
break;
}
}
}
return $components;
}
private function displayComponentDetails(SymfonyStyle $io, string $name): void
{
$metadata = $this->metadataFactory->getMetadata($name);
$table = $io->createTable();
$table->setHeaderTitle('Component');
$table->setHeaders(['Property', 'Value']);
$table->addRows([
['Name', $metadata->getComponentMetadata()->getName()],
['Class', $metadata->getComponentMetadata()->getClass()],
]);
$table->addRows([
['LiveProps', implode("\n", $this->getComponentLiveProps($metadata))],
['LiveListeners', implode("\n", $this->getComponentLiveListeners($metadata->getComponentMetadata()->getClass()))],
]);
$table->render();
}
/**
* @param array<string, LiveComponentMetadata> $components
*/
private function displayComponentsTable(array $components, SymfonyStyle $io): void
{
$table = $io->createTable();
$table->setStyle('default');
$table->setHeaderTitle('Components');
$table->setHeaders(['Name', 'Class']);
foreach ($components as $component) {
$table->addRow([
$component->getComponentMetadata()->getName(),
$component->getComponentMetadata()->getClass() ?? '',
]);
}
$table->render();
}
/**
* @return array<string, string>
*/
private function getComponentLiveProps(LiveComponentMetadata $component): array
{
$liveProps = [];
foreach ($component->getAllLivePropsMetadata(null) as $liveProp) {
$reflection = new \ReflectionProperty($component->getComponentMetadata()->getClass(), $liveProp->getName());
$type = $this->displayType($liveProp->getType());
$propertyName = '$'.$liveProp->getName();
$defaultValueDisplay = $reflection->hasDefaultValue() ?
$this->displayDefaultValue($reflection->getDefaultValue()) :
'';
$arguments = $reflection->getAttributes(LiveProp::class)[0]->getArguments();
$argumentsDisplay = empty($arguments) ?
'' :
' ('.implode(', ', array_map(
static fn ($key, $value) => $key.': '.json_encode($value),
array_keys($arguments),
$arguments
)).')';
$propertyDisplay = $type.$propertyName.$defaultValueDisplay.$argumentsDisplay;
$liveProps[$liveProp->getName()] = $propertyDisplay;
}
return $liveProps;
}
/**
* @return array<string, string>
*/
private function getComponentLiveListeners(string $class): array
{
$events = [];
foreach (AsLiveComponent::liveListeners($class) as $liveListener) {
$name = $liveListener['event'];
$methodName = $liveListener['action'];
$method = new \ReflectionMethod($class, $methodName);
$parameters = array_map(
fn (\ReflectionParameter $parameter) => $this->displayType($parameter->getType()).'$'.$parameter->getName().$this->displayDefaultValue($parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null),
array_filter(
$method->getParameters(),
static fn (\ReflectionParameter $parameter) => !empty($parameter->getAttributes(LiveArg::class))
)
);
$parametersDisplay = empty($parameters) ?
'' :
' ('.implode(', ', $parameters).')';
$display = $name.' => '.$methodName.$parametersDisplay;
$events[] = $display;
}
return $events;
}
private function displayType(Type|string|null $type): string
{
$display = (string) $type;
if ($type instanceof Type && $type->isNullable() && !str_contains($display, 'null')) {
$display = '?'.$display;
}
if ('' !== $display) {
$display .= ' ';
}
return $display;
}
private function displayDefaultValue(mixed $defaultValue): string
{
return (null !== $defaultValue) ?
' = '.json_encode($defaultValue) :
'';
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* @internal
*/
final class LiveComponentPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$liveComponentList = [];
foreach ($container->findTaggedServiceIds('twig.component') as $id => $tags) {
if (!($tags[0]['live'] ?? false)) {
continue;
}
$className = $container->findDefinition($id)->getClass();
$liveComponentList[$className] = $className;
}
$debugCommandDefinition = $container->findDefinition('ux.live_component.command.debug');
$debugCommandDefinition->setArgument(1, $liveComponentList);
}
}

View File

@@ -15,6 +15,7 @@ use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -26,9 +27,11 @@ use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Routing\RouterInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Command\LiveComponentDebugCommand;
use Symfony\UX\LiveComponent\ComponentValidator;
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
use Symfony\UX\LiveComponent\Controller\BatchActionController;
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\LiveComponentPass;
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
use Symfony\UX\LiveComponent\EventListener\DataModelPropsSubscriber;
use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber;
@@ -275,6 +278,13 @@ final class LiveComponentExtension extends Extension implements PrependExtension
new Parameter('container.build_hash'),
])
->addTag('kernel.cache_warmer');
$container->register('ux.live_component.command.debug', LiveComponentDebugCommand::class)
->setArguments([
new Reference('ux.live_component.metadata_factory'),
new AbstractArgument(\sprintf('Added in %s.', LiveComponentPass::class)),
])
->addTag('console.command');
}
public function getConfigTreeBuilder(): TreeBuilder

View File

@@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\ComponentDefaultActionPass;
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\LiveComponentPass;
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\OptionalDependencyPass;
/**
@@ -29,6 +30,7 @@ final class LiveComponentBundle extends Bundle
// must run before Symfony\Component\Serializer\DependencyInjection\SerializerPass
$container->addCompilerPass(new OptionalDependencyPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100);
$container->addCompilerPass(new ComponentDefaultActionPass());
$container->addCompilerPass(new LiveComponentPass());
}
public function getPath(): string

View File

@@ -125,8 +125,11 @@ final class LegacyLivePropMetadata
* If a modifier is specified, a modified clone is returned.
* Otherwise, the metadata is returned as it is.
*/
public function withModifier(object $component): self
public function withModifier(?object $component): self
{
if (null === $component) {
return $this;
}
if (null === ($modifier = $this->liveProp->modifier())) {
return $this;
}

View File

@@ -39,7 +39,7 @@ class LiveComponentMetadata
/**
* @return list<LivePropMetadata|LegacyLivePropMetadata>
*/
public function getAllLivePropsMetadata(object $component): iterable
public function getAllLivePropsMetadata(?object $component): iterable
{
foreach ($this->livePropsMetadata as $livePropMetadata) {
yield $livePropMetadata->withModifier($component);

View File

@@ -107,8 +107,11 @@ final class LivePropMetadata
* If a modifier is specified, a modified clone is returned.
* Otherwise, the metadata is returned as it is.
*/
public function withModifier(object $component): self
public function withModifier(?object $component): self
{
if (null === $component) {
return $this;
}
if (null === ($modifier = $this->liveProp->modifier())) {
return $this;
}

View File

@@ -0,0 +1,188 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\Tests\Integration\Command;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
class LiveComponentDebugCommandTest extends KernelTestCase
{
public function testList()
{
$commandTester = $this->createCommandTester();
$commandTester->execute([]);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->assertStringContainsString('Name', $display);
$this->assertStringContainsString('Class', $display);
$this->assertStringContainsString('component1', $display);
$this->assertStringContainsString('component2', $display);
}
public function testListListeningToEvent()
{
$commandTester = $this->createCommandTester();
$commandTester->execute(['--listening' => 'the_event_name']);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->assertStringContainsString('Name', $display);
$this->assertStringContainsString('Class', $display);
$this->assertStringNotContainsString('component1', $display);
$this->assertStringContainsString('component5', $display);
}
public function testEmptyListListeningToEvent()
{
$commandTester = $this->createCommandTester();
$commandTester->execute(['--listening' => 'event_not_defined']);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->assertStringContainsString('Name', $display);
$this->assertStringContainsString('Class', $display);
$this->assertStringNotContainsString('component1', $display);
$this->assertStringNotContainsString('component5', $display);
}
public function testWithNoMatchComponent()
{
$commandTester = $this->createCommandTester();
$result = $commandTester->execute(['name' => 'NoMatchComponent']);
$this->assertEquals(1, $result);
$this->assertStringContainsString('Unknown component "NoMatchComponent".', $commandTester->getDisplay());
}
public function testNotLiveComponentsIsNotListed()
{
$commandTester = $this->createCommandTester();
$result = $commandTester->execute(['name' => 'SimpleTwigComponent']);
$this->assertEquals(1, $result);
$this->assertStringContainsString('Unknown component "SimpleTwigComponent".', $commandTester->getDisplay());
}
public function testWithOnePartialMatchComponent()
{
$commandTester = $this->createCommandTester();
$commandTester->setInputs([]);
$result = $commandTester->execute(['name' => 'todo_list_']);
$this->assertEquals(0, $result);
// Choices
$this->assertStringNotContainsString('] todo_list\n', $commandTester->getDisplay());
$this->assertStringContainsString('] todo_list_with_keys', $commandTester->getDisplay());
// Component table
$this->assertStringContainsString('Component\\TodoListWithKeysComponent', $commandTester->getDisplay());
}
public function testWithMultiplePartialMatchComponent()
{
$commandTester = $this->createCommandTester();
$commandTester->setInputs(['todo_list']);
$result = $commandTester->execute(['name' => 'todo_']);
$this->assertEquals(0, $result);
// Choices
$this->assertStringContainsString('Select one of the following component to display its information', $commandTester->getDisplay());
$this->assertStringContainsString('] todo_item', $commandTester->getDisplay());
$this->assertStringContainsString('] todo_list', $commandTester->getDisplay());
$this->assertStringContainsString('] todo_list_with_keys', $commandTester->getDisplay());
// Component table
$this->assertStringNotContainsString('Component\\TodoItemComponent', $commandTester->getDisplay());
$this->assertStringContainsString('Component\\TodoListComponent', $commandTester->getDisplay());
$this->assertStringNotContainsString('Component\\TodoListWithKeysComponent', $commandTester->getDisplay());
}
public function testComponent()
{
$commandTester = $this->createCommandTester();
$commandTester->execute(['name' => 'component1']);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->tableDisplayCheck($display);
$this->assertStringContainsString('component1', $display);
$this->assertStringContainsString('Component\\Component1', $display);
}
public function testLivePropsWithFieldName()
{
$commandTester = $this->createCommandTester();
$commandTester->execute(['name' => 'component3']);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->tableDisplayCheck($display);
$this->assertStringContainsString('component3', $display);
$this->assertStringContainsString('LiveProps', $display);
$this->assertStringContainsString('$prop1 (fieldName: "myProp1")', $display);
$this->assertStringContainsString('$prop2 (fieldName: "getProp2Name()"', $display);
}
public function testLivePropsWithUrlMapping()
{
$commandTester = $this->createCommandTester();
$commandTester->execute(['name' => 'component_with_url_bound_props']);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->tableDisplayCheck($display);
$this->assertStringContainsString('component_with_url_bound_props', $display);
$this->assertStringContainsString('LiveProps', $display);
$this->assertStringContainsString('$pathPropWithAlias (writable: true, url: {"as":"pathAlias","mapPath":true})', $display);
}
public function testWithLiveListeners()
{
$commandTester = $this->createCommandTester();
$commandTester->execute(['name' => 'component2']);
$commandTester->assertCommandIsSuccessful();
$display = $commandTester->getDisplay();
$this->tableDisplayCheck($display);
$this->assertStringContainsString('component2', $display);
$this->assertStringContainsString('LiveListeners', $display);
$this->assertStringContainsString('triggerIncrease => increaseEvent1 (int $amount = 1)', $display);
$this->assertStringContainsString('triggerIncrease => increaseEvent2 (int $amount = 1)', $display);
}
private function createCommandTester(): CommandTester
{
$kernel = self::bootKernel();
$application = new Application($kernel);
return new CommandTester($application->find('debug:live-component'));
}
private function tableDisplayCheck(string $display): void
{
$this->assertStringContainsString('Component', $display);
$this->assertStringContainsString('Class', $display);
$this->assertStringContainsString('LiveProps', $display);
$this->assertStringContainsString('LiveListeners', $display);
}
}