Skip to content

Commit a6096e3

Browse files
author
Mickaël BULIARD
committed
LiveComponentDebugCommand
1 parent d6c9184 commit a6096e3

6 files changed

Lines changed: 497 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
22+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
23+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
24+
25+
#[AsCommand(name: 'debug:live-component', description: 'Display live components and their usage for an application')]
26+
class LiveComponentDebugCommand extends Command
27+
{
28+
public function __construct(
29+
protected readonly array $componentClassMap,
30+
) {
31+
parent::__construct();
32+
}
33+
34+
protected function configure(): void
35+
{
36+
$this
37+
->setDefinition([
38+
new InputArgument(
39+
'name',
40+
InputArgument::OPTIONAL,
41+
'A LiveComponent name or part of the name'
42+
),
43+
new InputOption(
44+
name: 'listening',
45+
mode: InputOption::VALUE_REQUIRED,
46+
description: 'Filter list to display only those listening to the given event'
47+
),
48+
])
49+
->setHelp(
50+
<<<'EOF'
51+
The <info>%command.name%</info> display all the live components in your application.
52+
53+
To list all live components:
54+
55+
<info>php %command.full_name%</info>
56+
57+
To get specific information about a component, specify its name (or a part of it):
58+
59+
<info>php %command.full_name% Alert</info>
60+
EOF
61+
);
62+
}
63+
64+
protected function execute(InputInterface $input, OutputInterface $output): int
65+
{
66+
$io = new SymfonyStyle($input, $output);
67+
$name = $input->getArgument('name');
68+
69+
if (\is_string($name)) {
70+
$componentName = $this->findComponentName($io, $name, $input->isInteractive());
71+
if (null === $componentName) {
72+
$io->error(\sprintf('Unknown LiveComponent "%s".', $name));
73+
74+
return Command::FAILURE;
75+
}
76+
77+
$this->displayComponentDetails($io, $componentName);
78+
79+
return Command::SUCCESS;
80+
}
81+
82+
$components = $this->listComponents($input->getOption('listening'));
83+
84+
$this->displayComponentsTable($components, $io);
85+
86+
return Command::SUCCESS;
87+
}
88+
89+
private function findComponentName(SymfonyStyle $io, string $name, bool $interactive): ?string
90+
{
91+
$components = [];
92+
foreach ($this->componentClassMap as $component) {
93+
if ($name === $component['key']) {
94+
return $name;
95+
}
96+
if (str_contains($component['key'], $name)) {
97+
$components[$component['key']] = $component['key'];
98+
}
99+
}
100+
101+
if ($interactive && \count($components)) {
102+
return $io->choice('Select one of the following component to display its information', array_values($components), 0);
103+
}
104+
105+
return null;
106+
}
107+
108+
private function listComponents(?string $eventFilter = null): array
109+
{
110+
if (null === $eventFilter) {
111+
return $this->componentClassMap;
112+
}
113+
114+
$filteredComponents = [];
115+
foreach ($this->componentClassMap as $name => $component) {
116+
foreach (AsLiveComponent::liveListeners($component['class']) as $listener) {
117+
if ($listener['event'] === $eventFilter) {
118+
$filteredComponents[$name] = $component;
119+
break;
120+
}
121+
}
122+
}
123+
124+
return $filteredComponents;
125+
}
126+
127+
private function displayComponentDetails(SymfonyStyle $io, string $name): void
128+
{
129+
$component = $this->componentClassMap[$name];
130+
131+
$table = $io->createTable();
132+
$table->setHeaderTitle('Component');
133+
$table->setHeaders(['Property', 'Value']);
134+
$table->addRows([
135+
['Name', $component['key']],
136+
['Class', $component['class']],
137+
]);
138+
139+
$table->addRows([
140+
['LiveProps', implode("\n", $this->getComponentLiveProps($component['class']))],
141+
['LiveListeners', implode("\n", $this->getComponentLiveListeners($component['class']))],
142+
]);
143+
144+
$table->render();
145+
}
146+
147+
private function displayComponentsTable(array $components, SymfonyStyle $io): void
148+
{
149+
$table = $io->createTable();
150+
$table->setStyle('default');
151+
$table->setHeaderTitle('Components');
152+
$table->setHeaders(['Name', 'Class']);
153+
foreach ($components as $component) {
154+
$table->addRow([
155+
$component['key'],
156+
$component['class'] ?? '',
157+
]);
158+
}
159+
$table->render();
160+
}
161+
162+
/**
163+
* @return array<string, string>
164+
*/
165+
private function getComponentLiveProps(string $class): array
166+
{
167+
$properties = [];
168+
$reflectionClass = new \ReflectionClass($class);
169+
foreach ($reflectionClass->getProperties() as $property) {
170+
if (!$property->isPublic()) {
171+
continue;
172+
}
173+
if (empty($property->getAttributes(LiveProp::class))) {
174+
continue;
175+
}
176+
177+
$type = $this->displayType($property->getType());
178+
$propertyName = '$'.$property->getName();
179+
$defaultValueDisplay = $property->hasDefaultValue() ?
180+
$this->displayDefaultValue($property->getDefaultValue()) :
181+
'';
182+
$arguments = $property->getAttributes(LiveProp::class)[0]->getArguments();
183+
$argumentsDisplay = empty($arguments) ?
184+
'' :
185+
' ('.implode(', ', array_map(
186+
static fn ($key, $value) => $key.': '.json_encode($value),
187+
array_keys($arguments),
188+
$arguments
189+
)).')';
190+
191+
$propertyDisplay = $type.$propertyName.$defaultValueDisplay.$argumentsDisplay;
192+
$properties[$property->name] = $propertyDisplay;
193+
}
194+
195+
return $properties;
196+
}
197+
198+
/**
199+
* @return array<string, string>
200+
*/
201+
private function getComponentLiveListeners(string $class): array
202+
{
203+
$events = [];
204+
foreach (AsLiveComponent::liveListeners($class) as $liveListener) {
205+
$name = $liveListener['event'];
206+
$methodName = $liveListener['action'];
207+
$method = new \ReflectionMethod($class, $methodName);
208+
$parameters = array_map(
209+
fn (\ReflectionParameter $parameter) => $this->displayType($parameter->getType()).'$'.$parameter->getName().$this->displayDefaultValue($parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null),
210+
array_filter(
211+
$method->getParameters(),
212+
static fn (\ReflectionParameter $parameter) => !empty($parameter->getAttributes(LiveArg::class))
213+
)
214+
);
215+
$parametersDisplay = empty($parameters) ?
216+
'' :
217+
' ('.implode(', ', $parameters).')';
218+
219+
$display = $name.' => '.$methodName.$parametersDisplay;
220+
$events[] = $display;
221+
}
222+
223+
return $events;
224+
}
225+
226+
private function displayType(?\ReflectionType $type): string
227+
{
228+
$display = (string) $type;
229+
if ($type instanceof \ReflectionNamedType) {
230+
$display = $type->getName();
231+
if ($type->allowsNull() && 'mixed' !== $display) {
232+
$display = '?'.$display;
233+
}
234+
}
235+
if ('' !== $display) {
236+
$display .= ' ';
237+
}
238+
239+
return $display;
240+
}
241+
242+
private function displayDefaultValue(mixed $defaultValue): string
243+
{
244+
return (null !== $defaultValue) ?
245+
' = '.json_encode($defaultValue) :
246+
'';
247+
}
248+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\DependencyInjection\Compiler;
13+
14+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
15+
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
17+
/**
18+
* @internal
19+
*/
20+
final class LiveComponentPass implements CompilerPassInterface
21+
{
22+
public function process(ContainerBuilder $container): void
23+
{
24+
$componentClassMap = [];
25+
foreach ($container->findTaggedServiceIds('twig.component') as $id => $tags) {
26+
if (!($tags[0]['live'] ?? false)) {
27+
continue;
28+
}
29+
30+
$definition = $container->findDefinition($id);
31+
32+
foreach ($tags as $tag) {
33+
if (!\array_key_exists('key', $tag)) {
34+
continue;
35+
}
36+
37+
$tag['class'] = $definition->getClass();
38+
$componentClassMap[$tag['key']] = $tag;
39+
}
40+
}
41+
42+
$componentPropertiesDefinition = $container->findDefinition('ux.twig_component.component_properties');
43+
$componentPropertiesDefinition->setArgument(1, array_fill_keys(array_keys($componentClassMap), null));
44+
$debugCommandDefinition = $container->findDefinition('ux.live_component.command.debug');
45+
$debugCommandDefinition->setArgument(0, $componentClassMap);
46+
}
47+
}

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
1616
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
1717
use Symfony\Component\Config\Definition\ConfigurationInterface;
18+
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
1819
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
1920
use Symfony\Component\DependencyInjection\ChildDefinition;
2021
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -26,9 +27,11 @@
2627
use Symfony\Component\DependencyInjection\Reference;
2728
use Symfony\Component\Routing\RouterInterface;
2829
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
30+
use Symfony\UX\LiveComponent\Command\LiveComponentDebugCommand;
2931
use Symfony\UX\LiveComponent\ComponentValidator;
3032
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
3133
use Symfony\UX\LiveComponent\Controller\BatchActionController;
34+
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\LiveComponentPass;
3235
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
3336
use Symfony\UX\LiveComponent\EventListener\DataModelPropsSubscriber;
3437
use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber;
@@ -275,6 +278,12 @@ static function (ChildDefinition $definition, AsLiveComponent $attribute) {
275278
new Parameter('container.build_hash'),
276279
])
277280
->addTag('kernel.cache_warmer');
281+
282+
$container->register('ux.live_component.command.debug', LiveComponentDebugCommand::class)
283+
->setArguments([
284+
new AbstractArgument(\sprintf('Added in %s.', LiveComponentPass::class)),
285+
])
286+
->addTag('console.command');
278287
}
279288

280289
public function getConfigTreeBuilder(): TreeBuilder

src/LiveComponent/src/LiveComponentBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
1616
use Symfony\Component\HttpKernel\Bundle\Bundle;
1717
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\ComponentDefaultActionPass;
18+
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\LiveComponentPass;
1819
use Symfony\UX\LiveComponent\DependencyInjection\Compiler\OptionalDependencyPass;
1920

2021
/**
@@ -29,6 +30,7 @@ public function build(ContainerBuilder $container): void
2930
// must run before Symfony\Component\Serializer\DependencyInjection\SerializerPass
3031
$container->addCompilerPass(new OptionalDependencyPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100);
3132
$container->addCompilerPass(new ComponentDefaultActionPass());
33+
$container->addCompilerPass(new LiveComponentPass());
3234
}
3335

3436
public function getPath(): string

0 commit comments

Comments
 (0)