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 %}
+
+
{% 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"
},