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
+ ×
+
+
+
+
+
+ event
+ listener
+
+
+
+
+
+
+ ⌨ esc
+ Close
+
+
{% 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