diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php index c37373e0f605a..4909b7d6921ef 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php @@ -22,6 +22,8 @@ ]) ->args([ tagged_iterator('workflow', 'name'), + service('event_dispatcher'), + service('debug.file_link_formatter'), ]) ; }; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig index 05bc799536bb6..377b74f609f21 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig @@ -1,5 +1,102 @@ {% extends '@WebProfiler/Profiler/layout.html.twig' %} +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + {% block toolbar %} {% if collector.callsCount > 0 %} {% set icon %} @@ -40,6 +137,93 @@ flowchart: { useMaxWidth: false }, securityLevel: 'loose', }); + + {% for name, data in collector.workflows %} + window.showNodeDetails{{ collector.hash(name) }} = function (node) { + const map = {{ data.listeners|json_encode|raw }}; + showNodeDetails(node, map); + }; + {% endfor %} + + const showNodeDetails = function (node, map) { + const dialog = document.getElementById('detailsDialog'); + + dialog.querySelector('tbody').innerHTML = ''; + for (const [eventName, listeners] of Object.entries(map[node])) { + listeners.forEach(listener => { + const row = document.createElement('tr'); + + const eventNameCode = document.createElement('code'); + eventNameCode.textContent = eventName; + + const eventNameCell = document.createElement('td'); + eventNameCell.appendChild(eventNameCode); + row.appendChild(eventNameCell); + + const listenerDetailsCell = document.createElement('td'); + row.appendChild(listenerDetailsCell); + + let listenerDetails; + const listenerDetailsCode = document.createElement('code'); + listenerDetailsCode.textContent = listener.title; + if (listener.file) { + const link = document.createElement('a'); + link.href = listener.file; + link.appendChild(listenerDetailsCode); + listenerDetails = link; + } else { + listenerDetails = listenerDetailsCode; + } + listenerDetailsCell.appendChild(listenerDetails); + + if (typeof listener.guardExpressions === 'object') { + listenerDetailsCell.appendChild(document.createElement('br')); + + const guardExpressionsWrapper = document.createElement('span'); + guardExpressionsWrapper.appendChild(document.createTextNode('guard expressions: ')); + + listener.guardExpressions.forEach((expression, index) => { + if (index > 0) { + guardExpressionsWrapper.appendChild(document.createTextNode(', ')); + } + + const expressionCode = document.createElement('code'); + expressionCode.textContent = expression; + guardExpressionsWrapper.appendChild(expressionCode); + }); + + listenerDetailsCell.appendChild(guardExpressionsWrapper); + } + + dialog.querySelector('tbody').appendChild(row); + }); + }; + + if (dialog.dataset.processed) { + dialog.showModal(); + return; + } + + dialog.addEventListener('click', (e) => { + const rect = dialog.getBoundingClientRect(); + + const inDialog = + rect.top <= e.clientY && + e.clientY <= rect.top + rect.height && + rect.left <= e.clientX && + e.clientX <= rect.left + rect.width; + + !inDialog && dialog.close(); + }); + + dialog.querySelectorAll('.cancel').forEach(elt => { + elt.addEventListener('click', () => dialog.close()); + }); + + dialog.showModal(); + + dialog.dataset.processed = true; + }; // We do not load all mermaid diagrams at once, but only when the tab is opened // This is because mermaid diagrams are in a tab, and cannot be renderer with a // "good size" if they are not visible @@ -71,6 +255,9 @@

Definition

                             {{ data.dump|raw }}
+                            {% for nodeId, events in data.listeners %}
+                                click {{ nodeId }} showNodeDetails{{ collector.hash(name) }}
+                            {% endfor %}
                         

Calls

@@ -128,4 +315,26 @@ {% endfor %} {% endif %} + + +

+ Event listeners + × +

+ + + + + + + + + + +
eventlistener
+ + esc + + +
{% endblock %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg index c6b9886f94f34..4f697a7a49b6e 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg @@ -1 +1,8 @@ - + + + + + + + + diff --git a/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php b/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php index 69780121b35c3..656594dff6871 100644 --- a/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php +++ b/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Workflow\DataCollector; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; @@ -19,8 +21,12 @@ use Symfony\Component\VarDumper\Cloner\Stub; use Symfony\Component\Workflow\Debug\TraceableWorkflow; use Symfony\Component\Workflow\Dumper\MermaidDumper; +use Symfony\Component\Workflow\EventListener\GuardExpression; +use Symfony\Component\Workflow\EventListener\GuardListener; use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\TransitionBlocker; +use Symfony\Component\Workflow\WorkflowInterface; /** * @author Grégoire Pineau @@ -29,6 +35,8 @@ final class WorkflowDataCollector extends DataCollector implements LateDataColle { public function __construct( private readonly iterable $workflows, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly FileLinkFormatter $fileLinkFormatter, ) { } @@ -50,6 +58,7 @@ public function lateCollect(): void $this->data['workflows'][$workflow->getName()] = [ 'dump' => $dumper->dump($workflow->getDefinition()), 'calls' => $calls, + 'listeners' => $this->getEventListeners($workflow), ]; } } @@ -102,4 +111,120 @@ protected function getCasters(): array return $casters; } + + public function hash(string $string): string + { + return hash('xxh128', $string); + } + + private function getEventListeners(WorkflowInterface $workflow): array + { + $listeners = []; + $placeId = 0; + foreach ($workflow->getDefinition()->getPlaces() as $place) { + $eventNames = []; + $subEventNames = [ + 'leave', + 'enter', + 'entered', + ]; + foreach ($subEventNames as $subEventName) { + $eventNames[] = sprintf('workflow.%s', $subEventName); + $eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); + $eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $place); + } + foreach ($eventNames as $eventName) { + foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { + $listeners["place{$placeId}"][$eventName][] = $this->summarizeListener($listener); + } + } + + ++$placeId; + } + + foreach ($workflow->getDefinition()->getTransitions() as $transitionId => $transition) { + $eventNames = []; + $subEventNames = [ + 'guard', + 'transition', + 'completed', + 'announce', + ]; + foreach ($subEventNames as $subEventName) { + $eventNames[] = sprintf('workflow.%s', $subEventName); + $eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); + $eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $transition->getName()); + } + foreach ($eventNames as $eventName) { + foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { + $listeners["transition{$transitionId}"][$eventName][] = $this->summarizeListener($listener, $eventName, $transition); + } + } + } + + return $listeners; + } + + private function summarizeListener(callable $callable, string $eventName = null, Transition $transition = null): array + { + $extra = []; + + if ($callable instanceof \Closure) { + $r = new \ReflectionFunction($callable); + if (str_contains($r->name, '{closure}')) { + $title = (string) $r; + } elseif ($class = \PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass()) { + $title = $class->name.'::'.$r->name.'()'; + } else { + $title = $r->name; + } + } elseif (\is_string($callable)) { + $title = $callable.'()'; + $r = new \ReflectionFunction($callable); + } elseif (\is_object($callable) && method_exists($callable, '__invoke')) { + $r = new \ReflectionMethod($callable, '__invoke'); + $title = $callable::class.'::__invoke()'; + } elseif (\is_array($callable)) { + if ($callable[0] instanceof GuardListener) { + if (null === $eventName || null === $transition) { + throw new \LogicException('Missing event name or transition.'); + } + $extra['guardExpressions'] = $this->extractGuardExpressions($callable[0], $eventName, $transition); + } + $r = new \ReflectionMethod($callable[0], $callable[1]); + $title = (\is_string($callable[0]) ? $callable[0] : \get_class($callable[0])).'::'.$callable[1].'()'; + } else { + throw new \RuntimeException('Unknown callable type.'); + } + + $file = null; + if ($r->isUserDefined()) { + $file = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine()); + } + + return [ + 'title' => $title, + 'file' => $file, + ...$extra, + ]; + } + + private function extractGuardExpressions(GuardListener $listener, string $eventName, Transition $transition): array + { + $configuration = (new \ReflectionProperty(GuardListener::class, 'configuration'))->getValue($listener); + + $expressions = []; + foreach ($configuration[$eventName] as $guard) { + if ($guard instanceof GuardExpression) { + if ($guard->getTransition() !== $transition) { + continue; + } + $expressions[] = $guard->getExpression(); + } else { + $expressions[] = $guard; + } + } + + return $expressions; + } } diff --git a/src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php b/src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php new file mode 100644 index 0000000000000..21b4fe6ecfe54 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Component\Workflow\DataCollector\WorkflowDataCollector; +use Symfony\Component\Workflow\EventListener\ExpressionLanguage; +use Symfony\Component\Workflow\EventListener\GuardListener; +use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; +use Symfony\Component\Workflow\Workflow; + +class WorkflowDataCollectorTest extends TestCase +{ + use WorkflowBuilderTrait; + + public function test() + { + $workflow1 = new Workflow($this->createComplexWorkflowDefinition(), name: 'workflow1'); + $workflow2 = new Workflow($this->createSimpleWorkflowDefinition(), name: 'workflow2'); + $dispatcher = new EventDispatcher(); + $dispatcher->addListener('workflow.workflow2.leave.a', fn () => true); + $dispatcher->addListener('workflow.workflow2.leave.a', [self::class, 'noop']); + $dispatcher->addListener('workflow.workflow2.leave.a', [$this, 'noop']); + $dispatcher->addListener('workflow.workflow2.leave.a', $this->noop(...)); + $dispatcher->addListener('workflow.workflow2.leave.a', 'var_dump'); + $guardListener = new GuardListener( + ['workflow.workflow2.guard.t1' => ['my_expression']], + $this->createMock(ExpressionLanguage::class), + $this->createMock(TokenStorageInterface::class), + $this->createMock(AuthorizationCheckerInterface::class), + $this->createMock(AuthenticationTrustResolverInterface::class), + $this->createMock(RoleHierarchyInterface::class), + $this->createMock(ValidatorInterface::class) + ); + $dispatcher->addListener('workflow.workflow2.guard.t1', [$guardListener, 'onTransition']); + + $collector = new WorkflowDataCollector( + [$workflow1, $workflow2], + $dispatcher, + new FileLinkFormatter(), + ); + + $collector->lateCollect(); + + $data = $collector->getWorkflows(); + + $this->assertArrayHasKey('workflow1', $data); + $this->assertArrayHasKey('dump', $data['workflow1']); + $this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']); + $this->assertArrayHasKey('listeners', $data['workflow1']); + + $this->assertSame([], $data['workflow1']['listeners']); + $this->assertArrayHasKey('workflow2', $data); + $this->assertArrayHasKey('dump', $data['workflow2']); + $this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']); + $this->assertArrayHasKey('listeners', $data['workflow2']); + $listeners = $data['workflow2']['listeners']; + $this->assertArrayHasKey('place0', $listeners); + $this->assertArrayHasKey('workflow.workflow2.leave.a', $listeners['place0']); + $descriptions = $listeners['place0']['workflow.workflow2.leave.a']; + $this->assertCount(5, $descriptions); + $this->assertStringContainsString('Closure', $descriptions[0]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[1]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[2]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[3]['title']); + $this->assertSame('var_dump()', $descriptions[4]['title']); + $this->assertArrayHasKey('transition0', $listeners); + $this->assertArrayHasKey('workflow.workflow2.guard.t1', $listeners['transition0']); + $this->assertSame('Symfony\Component\Workflow\EventListener\GuardListener::onTransition()', $listeners['transition0']['workflow.workflow2.guard.t1'][0]['title']); + $this->assertSame(['my_expression'], $listeners['transition0']['workflow.workflow2.guard.t1'][0]['guardExpressions']); + } + + public static function noop() + { + } +} diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json index 3a95fdd268c54..689219800eea1 100644 --- a/src/Symfony/Component/Workflow/composer.json +++ b/src/Symfony/Component/Workflow/composer.json @@ -26,8 +26,10 @@ "require-dev": { "psr/log": "^1|^2|^3", "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^6.4|^7.0", "symfony/event-dispatcher": "^5.4|^6.0|^7.0", "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", "symfony/security-core": "^5.4|^6.0|^7.0", "symfony/validator": "^5.4|^6.0|^7.0" }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy