Skip to content

Commit 0634766

Browse files
committed
feature #40171 [Workflow] Add Mermaid.js dumper (eFrane)
This PR was squashed before being merged into the 5.3-dev branch. Discussion ---------- [Workflow] Add Mermaid.js dumper | Q | A | ------------- | --- | Branch? | 5.x | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | #40165 | License | MIT | Doc PR | symfony/symfony-docs#15102 Mermaid is - next to PlantUML - one of the most popular simple graphing solutions. This workflow dumper mirrors the feature set of the PlantUML dumper except that Mermaid does not currently support colored transitions. **Things I need help with:** - ~I basically tried to copy the code style of the surrounding files and hope everything is conforming. Please let me know if I missed something.~ I see, that's the magic of fabbot. Nice. ❤️ - There are currently no tests for the different graph direction constants, I can add those, just did not see value in doing so yet. - I am unsure how to integrate this with the current documentation. This however is likely better discussed in the corresponding issue (see above). Commits ------- ada6f7d315 [Workflow] Add Mermaid.js dumper
2 parents ab19cbc + 1a6d598 commit 0634766

File tree

3 files changed

+515
-0
lines changed

3 files changed

+515
-0
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

77
* Deprecate `InvalidTokenConfigurationException`
8+
* Added `MermaidDumper` to dump Workflow graphs in the Mermaid.js flowchart format
89

910
5.2.0
1011
-----

Dumper/MermaidDumper.php

+288
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Component\Workflow\Dumper;
13+
14+
use Symfony\Component\Workflow\Definition;
15+
use Symfony\Component\Workflow\Exception\InvalidArgumentException;
16+
use Symfony\Component\Workflow\Marking;
17+
18+
class MermaidDumper implements DumperInterface
19+
{
20+
public const DIRECTION_TOP_TO_BOTTOM = 'TB';
21+
public const DIRECTION_TOP_DOWN = 'TD';
22+
public const DIRECTION_BOTTOM_TO_TOP = 'BT';
23+
public const DIRECTION_RIGHT_TO_LEFT = 'RL';
24+
public const DIRECTION_LEFT_TO_RIGHT = 'LR';
25+
26+
private const VALID_DIRECTIONS = [
27+
self::DIRECTION_TOP_TO_BOTTOM,
28+
self::DIRECTION_TOP_DOWN,
29+
self::DIRECTION_BOTTOM_TO_TOP,
30+
self::DIRECTION_RIGHT_TO_LEFT,
31+
self::DIRECTION_LEFT_TO_RIGHT,
32+
];
33+
34+
public const TRANSITION_TYPE_STATEMACHINE = 'statemachine';
35+
public const TRANSITION_TYPE_WORKFLOW = 'workflow';
36+
37+
private const VALID_TRANSITION_TYPES = [
38+
self::TRANSITION_TYPE_STATEMACHINE,
39+
self::TRANSITION_TYPE_WORKFLOW,
40+
];
41+
42+
/**
43+
* @var string
44+
*/
45+
private $direction;
46+
47+
/**
48+
* @var string
49+
*/
50+
private $transitionType;
51+
52+
/**
53+
* Just tracking the transition id is in some cases inaccurate to
54+
* get the link's number for styling purposes.
55+
*
56+
* @var int
57+
*/
58+
private $linkCount;
59+
60+
public function __construct(string $transitionType, string $direction = self::DIRECTION_LEFT_TO_RIGHT)
61+
{
62+
$this->validateDirection($direction);
63+
$this->validateTransitionType($transitionType);
64+
65+
$this->direction = $direction;
66+
$this->transitionType = $transitionType;
67+
}
68+
69+
public function dump(Definition $definition, Marking $marking = null, array $options = []): string
70+
{
71+
$this->linkCount = 0;
72+
$placeNameMap = [];
73+
$placeId = 0;
74+
75+
$output = ['graph '.$this->direction];
76+
77+
$meta = $definition->getMetadataStore();
78+
79+
foreach ($definition->getPlaces() as $place) {
80+
[$placeNode, $placeStyle] = $this->preparePlace(
81+
$placeId,
82+
$place,
83+
$meta->getPlaceMetadata($place),
84+
\in_array($place, $definition->getInitialPlaces()),
85+
null !== $marking && $marking->has($place)
86+
);
87+
88+
$output[] = $placeNode;
89+
90+
if ('' !== $placeStyle) {
91+
$output[] = $placeStyle;
92+
}
93+
94+
$placeNameMap[$place] = $place.$placeId;
95+
96+
++$placeId;
97+
}
98+
99+
foreach ($definition->getTransitions() as $transitionId => $transition) {
100+
$transitionMeta = $meta->getTransitionMetadata($transition);
101+
102+
$transitionLabel = $transition->getName();
103+
if (\array_key_exists('label', $transitionMeta)) {
104+
$transitionLabel = $transitionMeta['label'];
105+
}
106+
107+
foreach ($transition->getFroms() as $from) {
108+
$from = $placeNameMap[$from];
109+
110+
foreach ($transition->getTos() as $to) {
111+
$to = $placeNameMap[$to];
112+
113+
if (self::TRANSITION_TYPE_STATEMACHINE === $this->transitionType) {
114+
$transitionOutput = $this->styleStatemachineTransition(
115+
$from,
116+
$to,
117+
$transitionId,
118+
$transitionLabel,
119+
$transitionMeta
120+
);
121+
} else {
122+
$transitionOutput = $this->styleWorkflowTransition(
123+
$from,
124+
$to,
125+
$transitionId,
126+
$transitionLabel,
127+
$transitionMeta
128+
);
129+
}
130+
131+
foreach ($transitionOutput as $line) {
132+
if (\in_array($line, $output)) {
133+
// additional links must be decremented again to align the styling
134+
if (0 < strpos($line, '-->')) {
135+
--$this->linkCount;
136+
}
137+
138+
continue;
139+
}
140+
141+
$output[] = $line;
142+
}
143+
}
144+
}
145+
}
146+
147+
return implode("\n", $output);
148+
}
149+
150+
private function preparePlace(int $placeId, string $placeName, array $meta, bool $isInitial, bool $hasMarking): array
151+
{
152+
$placeLabel = $placeName;
153+
if (\array_key_exists('label', $meta)) {
154+
$placeLabel = $meta['label'];
155+
}
156+
157+
$placeLabel = $this->escape($placeLabel);
158+
159+
$labelShape = '((%s))';
160+
if ($isInitial) {
161+
$labelShape = '([%s])';
162+
}
163+
164+
$placeNodeName = $placeName.$placeId;
165+
$placeNodeFormat = '%s'.$labelShape;
166+
$placeNode = sprintf($placeNodeFormat, $placeNodeName, $placeLabel);
167+
168+
$placeStyle = $this->styleNode($meta, $placeNodeName, $hasMarking);
169+
170+
return [$placeNode, $placeStyle];
171+
}
172+
173+
private function styleNode(array $meta, string $nodeName, bool $hasMarking = false): string
174+
{
175+
$nodeStyles = [];
176+
177+
if (\array_key_exists('bg_color', $meta)) {
178+
$nodeStyles[] = sprintf(
179+
'fill:%s',
180+
$meta['bg_color']
181+
);
182+
}
183+
184+
if ($hasMarking) {
185+
$nodeStyles[] = 'stroke-width:4px';
186+
}
187+
188+
if (0 === \count($nodeStyles)) {
189+
return '';
190+
}
191+
192+
return sprintf('style %s %s', $nodeName, implode(',', $nodeStyles));
193+
}
194+
195+
/**
196+
* Replace double quotes with the mermaid escape syntax and
197+
* ensure all other characters are properly escaped.
198+
*/
199+
private function escape(string $label)
200+
{
201+
$label = str_replace('"', '#quot;', $label);
202+
203+
return sprintf('"%s"', $label);
204+
}
205+
206+
public function validateDirection(string $direction): void
207+
{
208+
if (!\in_array($direction, self::VALID_DIRECTIONS, true)) {
209+
throw new InvalidArgumentException(sprintf('Direction "%s" is not valid, valid directions are: "%s".', $direction, implode(', ', self::VALID_DIRECTIONS)));
210+
}
211+
}
212+
213+
private function validateTransitionType(string $transitionType): void
214+
{
215+
if (!\in_array($transitionType, self::VALID_TRANSITION_TYPES, true)) {
216+
throw new InvalidArgumentException(sprintf('Transition type "%s" is not valid, valid types are: "%s".', $transitionType, implode(', ', self::VALID_TRANSITION_TYPES)));
217+
}
218+
}
219+
220+
private function styleStatemachineTransition(
221+
string $from,
222+
string $to,
223+
int $transitionId,
224+
string $transitionLabel,
225+
array $transitionMeta
226+
): array {
227+
$transitionOutput = [sprintf('%s-->|%s|%s', $from, $this->escape($transitionLabel), $to)];
228+
229+
$linkStyle = $this->styleLink($transitionMeta);
230+
if ('' !== $linkStyle) {
231+
$transitionOutput[] = $linkStyle;
232+
}
233+
234+
++$this->linkCount;
235+
236+
return $transitionOutput;
237+
}
238+
239+
private function styleWorkflowTransition(
240+
string $from,
241+
string $to,
242+
int $transitionId,
243+
string $transitionLabel,
244+
array $transitionMeta
245+
) {
246+
$transitionOutput = [];
247+
248+
$transitionLabel = $this->escape($transitionLabel);
249+
$transitionNodeName = 'transition'.$transitionId;
250+
251+
$transitionOutput[] = sprintf('%s[%s]', $transitionNodeName, $transitionLabel);
252+
253+
$transitionNodeStyle = $this->styleNode($transitionMeta, $transitionNodeName);
254+
if ('' !== $transitionNodeStyle) {
255+
$transitionOutput[] = $transitionNodeStyle;
256+
}
257+
258+
$connectionStyle = '%s-->%s';
259+
$transitionOutput[] = sprintf($connectionStyle, $from, $transitionNodeName);
260+
261+
$linkStyle = $this->styleLink($transitionMeta);
262+
if ('' !== $linkStyle) {
263+
$transitionOutput[] = $linkStyle;
264+
}
265+
266+
++$this->linkCount;
267+
268+
$transitionOutput[] = sprintf($connectionStyle, $transitionNodeName, $to);
269+
270+
$linkStyle = $this->styleLink($transitionMeta);
271+
if ('' !== $linkStyle) {
272+
$transitionOutput[] = $linkStyle;
273+
}
274+
275+
++$this->linkCount;
276+
277+
return $transitionOutput;
278+
}
279+
280+
private function styleLink(array $transitionMeta): string
281+
{
282+
if (\array_key_exists('color', $transitionMeta)) {
283+
return sprintf('linkStyle %d stroke:%s', $this->linkCount, $transitionMeta['color']);
284+
}
285+
286+
return '';
287+
}
288+
}

0 commit comments

Comments
 (0)