mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-15 06:19:24 +02:00
Initial implementation for Issue#2249.
Includes changes to sequence diagram code to enable popup menus and individualized styling of actors Includes unit and e2e tests. Includes updates to the md file for sequencediagrams.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ local/
|
||||
|
||||
_site
|
||||
Gemfile.lock
|
||||
/.vs
|
||||
|
@@ -550,6 +550,46 @@ context('Sequence diagram', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
context('links', () => {
|
||||
it('should support actor links and properties', () => {
|
||||
imgSnapshotTest(
|
||||
`
|
||||
%%{init: { "config": { "mirrorActors": true, "forceMenus": true }}}%%
|
||||
sequenceDiagram
|
||||
participant a as Alice
|
||||
participant j as John
|
||||
note right of a: Hello world!
|
||||
properties a: {"class": "internal-service-actor", "type": "@clock"}
|
||||
properties j: {"class": "external-service-actor", "type": "@computer"}
|
||||
links a: {"Repo": "https://www.contoso.com/repo", "Swagger": "https://www.contoso.com/swagger"}
|
||||
links j: {"Repo": "https://www.contoso.com/repo"}
|
||||
links a: {"Dashboard": "https://www.contoso.com/dashboard", "On-Call": "https://www.contoso.com/oncall"}
|
||||
a->>j: Hello John, how are you?
|
||||
j-->>a: Great!
|
||||
`,
|
||||
{ logLevel: 0, sequence: { mirrorActors: true, noteFontSize: 18, noteFontFamily: 'Arial' } }
|
||||
);
|
||||
});
|
||||
it('should support actor links and properties when not mirrored', () => {
|
||||
imgSnapshotTest(
|
||||
`
|
||||
%%{init: { "config": { "mirrorActors": false, "forceMenus": true, "wrap": true }}}%%
|
||||
sequenceDiagram
|
||||
participant a as Alice
|
||||
participant j as John
|
||||
note right of a: Hello world!
|
||||
properties a: {"class": "internal-service-actor", "type": "@clock"}
|
||||
properties j: {"class": "external-service-actor", "type": "@computer"}
|
||||
links a: {"Repo": "https://www.contoso.com/repo", "Swagger": "https://www.contoso.com/swagger"}
|
||||
links j: {"Repo": "https://www.contoso.com/repo"}
|
||||
links a: {"Dashboard": "https://www.contoso.com/dashboard", "On-Call": "https://www.contoso.com/oncall"}
|
||||
a->>j: Hello John, how are you?
|
||||
j-->>a: Great!
|
||||
`,
|
||||
{ logLevel: 0, sequence: { mirrorActors: false, noteFontSize: 18, noteFontFamily: 'Arial' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
context('svg size', () => {
|
||||
it('should render a sequence diagram when useMaxWidth is true (default)', () => {
|
||||
renderGraph(
|
||||
|
@@ -406,6 +406,58 @@ sequenceDiagram
|
||||
Bob-->>John: Jolly good!
|
||||
```
|
||||
|
||||
## Actor Menus
|
||||
|
||||
Actors can have popup-menus containing individualized links to external pages. For example, if an actor represented a web service, useful links might include a link to the service health dashboard, repo containing the code for the service, or a wiki page describing the service.
|
||||
This can be configured by adding the links lines with the format:
|
||||
|
||||
links <actor>: <json-formatted link-name link-url pairs>
|
||||
|
||||
An example is below:
|
||||
|
||||
```
|
||||
sequenceDiagram
|
||||
participant Alice
|
||||
participant John
|
||||
links Alice: {"Dashboard": "https://dashboard.contoso.com/alice", "Wiki": "https://wiki.contoso.com/alice"}
|
||||
links John: {"Dashboard": "https://dashboard.contoso.com/john", "Wiki": "https://wiki.contoso.com/john"}
|
||||
Alice->>John: Hello John, how are you?
|
||||
John-->>Alice: Great!
|
||||
Alice-)John: See you later!
|
||||
```
|
||||
|
||||
## Actor Individualized Styles & Icons
|
||||
|
||||
Actors can have individualized styling including an embedded icon.
|
||||
This can be configured by adding the properties lines with this format:
|
||||
|
||||
properties <actor>: { "class": "<css className>", "icon": @<built-in-icon-name> -or- <url to an image file>
|
||||
>
|
||||
|
||||
```
|
||||
sequenceDiagram
|
||||
participant Alice
|
||||
participant John
|
||||
properties Alice: {"class": "scheduled-job-actor", "icon": "@clock"}
|
||||
properties John: {"class": "database-service-actor", "icon": "https://icons.contoso.com/database.svg"}
|
||||
Alice->>John: Hello John, how are you?
|
||||
John-->>Alice: Great!
|
||||
Alice-)John: See you later!
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Alice
|
||||
participant John
|
||||
properties Alice: {"icon": "@clock"}
|
||||
properties John: {"icon": "@database"}
|
||||
Alice->>John: Hello John, how are you?
|
||||
John-->>Alice: Great!
|
||||
Alice-)John: See you later!
|
||||
```
|
||||
|
||||
Built-in icon names include @clock, @database, @computer.
|
||||
|
||||
## Styling
|
||||
|
||||
Styling of a sequence diagram is done by defining a number of css classes. During rendering these classes are extracted from the file located at src/themes/sequence.scss
|
||||
|
@@ -329,6 +329,17 @@ const config = {
|
||||
*/
|
||||
mirrorActors: true,
|
||||
|
||||
/**
|
||||
*| Parameter | Description |Type | Required | Values|
|
||||
*| --- | --- | --- | --- | --- |
|
||||
*| forceMenus | forces actor popup menus to always be visible (to support E2E testing). | Boolean| Required | True, False |
|
||||
*
|
||||
* **Notes:**
|
||||
*
|
||||
* Default value: false.
|
||||
*/
|
||||
forceMenus: false,
|
||||
|
||||
/**
|
||||
*| Parameter | Description |Type | Required | Values|
|
||||
*| --- | --- | --- | --- | --- |
|
||||
|
@@ -47,6 +47,9 @@
|
||||
"end" return 'end';
|
||||
"left of" return 'left_of';
|
||||
"right of" return 'right_of';
|
||||
"links" return 'links';
|
||||
"properties" return 'properties';
|
||||
"details" return 'details';
|
||||
"over" return 'over';
|
||||
"note" return 'note';
|
||||
"activate" { this.begin('ID'); return 'activate'; }
|
||||
@@ -110,6 +113,9 @@ statement
|
||||
| 'activate' actor 'NEWLINE' {$$={type: 'activeStart', signalType: yy.LINETYPE.ACTIVE_START, actor: $2};}
|
||||
| 'deactivate' actor 'NEWLINE' {$$={type: 'activeEnd', signalType: yy.LINETYPE.ACTIVE_END, actor: $2};}
|
||||
| note_statement 'NEWLINE'
|
||||
| links_statement 'NEWLINE'
|
||||
| properties_statement 'NEWLINE'
|
||||
| details_statement 'NEWLINE'
|
||||
| title text2 'NEWLINE' {$$=[{type:'setTitle', text:$2}]}
|
||||
| 'loop' restOfLine document end
|
||||
{
|
||||
@@ -170,6 +176,27 @@ note_statement
|
||||
$$ = [$3, {type:'addNote', placement:yy.PLACEMENT.OVER, actor:$2.slice(0, 2), text:$4}];}
|
||||
;
|
||||
|
||||
links_statement
|
||||
: 'links' actor text2
|
||||
{
|
||||
$$ = [$2, {type:'addLinks', actor:$2.actor, text:$3}];
|
||||
}
|
||||
;
|
||||
|
||||
properties_statement
|
||||
: 'properties' actor text2
|
||||
{
|
||||
$$ = [$2, {type:'addProperties', actor:$2.actor, text:$3}];
|
||||
}
|
||||
;
|
||||
|
||||
details_statement
|
||||
: 'details' actor text2
|
||||
{
|
||||
$$ = [$2, {type:'addDetails', actor:$2.actor, text:$3}];
|
||||
}
|
||||
;
|
||||
|
||||
spaceList
|
||||
: SPACE spaceList
|
||||
| SPACE
|
||||
|
@@ -29,7 +29,11 @@ export const addActor = function(id, name, description) {
|
||||
name: name,
|
||||
description: description.text,
|
||||
wrap: (description.wrap === undefined && autoWrap()) || !!description.wrap,
|
||||
prevActor: prevActor
|
||||
prevActor: prevActor,
|
||||
links: {},
|
||||
properties: {},
|
||||
actorCnt: null,
|
||||
rectData: null,
|
||||
};
|
||||
if (prevActor && actors[prevActor]) {
|
||||
actors[prevActor].nextActor = id;
|
||||
@@ -206,6 +210,87 @@ export const addNote = function(actor, placement, message) {
|
||||
});
|
||||
};
|
||||
|
||||
export const addLinks = function (actorId, text) {
|
||||
// find the actor
|
||||
const actor = getActor(actorId);
|
||||
// JSON.parse the text
|
||||
try {
|
||||
const links = JSON.parse(text.text);
|
||||
// add the deserialized text to the actor's links field.
|
||||
insertLinks(actor, links);
|
||||
}
|
||||
catch (e) {
|
||||
log.error('error while parsing actor link text', e);
|
||||
}
|
||||
};
|
||||
|
||||
function insertLinks(actor, links) {
|
||||
if (actor.links == null) {
|
||||
actor.links = links;
|
||||
}
|
||||
else {
|
||||
for (let key in links) {
|
||||
actor.links[key] = links[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const addProperties = function (actorId, text) {
|
||||
// find the actor
|
||||
const actor = getActor(actorId);
|
||||
// JSON.parse the text
|
||||
try {
|
||||
const properties = JSON.parse(text.text);
|
||||
// add the deserialized text to the actor's property field.
|
||||
insertProperties(actor, properties);
|
||||
}
|
||||
catch (e) {
|
||||
log.error('error while parsing actor properties text', e);
|
||||
}
|
||||
};
|
||||
|
||||
function insertProperties(actor, properties) {
|
||||
if (actor.properties == null) {
|
||||
actor.properties = properties;
|
||||
}
|
||||
else {
|
||||
for (let key in properties) {
|
||||
actor.properties[key] = properties[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const addDetails = function (actorId, text) {
|
||||
// find the actor
|
||||
const actor = getActor(actorId);
|
||||
const elem = document.getElementById(text.text);
|
||||
|
||||
// JSON.parse the text
|
||||
try {
|
||||
const text = elem.innerHTML;
|
||||
const details = JSON.parse(text);
|
||||
// add the deserialized text to the actor's property field.
|
||||
if (details["properties"]) {
|
||||
insertProperties(actor, details["properties"]);
|
||||
}
|
||||
|
||||
if (details["links"]) {
|
||||
insertLinks(actor, details["links"]);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
log.error('error while parsing actor details text', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getActorProperty = function (actor, key) {
|
||||
if (typeof actor !== 'undefined' && typeof actor.properties !== 'undefined') {
|
||||
return actor.properties[key];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const setTitle = function(titleWrap) {
|
||||
title = titleWrap.text;
|
||||
titleWrapped = (titleWrap.wrap === undefined && autoWrap()) || !!titleWrap.wrap;
|
||||
@@ -230,6 +315,15 @@ export const apply = function(param) {
|
||||
case 'addNote':
|
||||
addNote(param.actor, param.placement, param.text);
|
||||
break;
|
||||
case 'addLinks':
|
||||
addLinks(param.actor, param.text);
|
||||
break;
|
||||
case 'addProperties':
|
||||
addProperties(param.actor, param.text);
|
||||
break;
|
||||
case 'addDetails':
|
||||
addDetails(param.actor, param.text);
|
||||
break;
|
||||
case 'addMessage':
|
||||
addSignal(param.from, param.to, param.msg, param.signalType);
|
||||
break;
|
||||
@@ -280,6 +374,9 @@ export default {
|
||||
addActor,
|
||||
addMessage,
|
||||
addSignal,
|
||||
addLinks,
|
||||
addDetails,
|
||||
addProperties,
|
||||
autoWrap,
|
||||
setWrap,
|
||||
enableSequenceNumbers,
|
||||
@@ -288,6 +385,7 @@ export default {
|
||||
getActors,
|
||||
getActor,
|
||||
getActorKeys,
|
||||
getActorProperty,
|
||||
getTitle,
|
||||
parseDirective,
|
||||
getConfig: () => configApi.getConfig().sequence,
|
||||
|
@@ -898,6 +898,49 @@ end`;
|
||||
expect(messages[3].message).toBe('');
|
||||
expect(messages[4].message).toBe('I am good thanks!');
|
||||
});
|
||||
|
||||
it('it should handle links', function () {
|
||||
const str = `
|
||||
sequenceDiagram
|
||||
participant a as Alice
|
||||
participant b as Bob
|
||||
participant c as Charlie
|
||||
links a: { "Repo": "https://repo.contoso.com/", "Dashboard": "https://dashboard.contoso.com/" }
|
||||
links b: { "Dashboard": "https://dashboard.contoso.com/" }
|
||||
links a: { "On-Call": "https://oncall.contoso.com/?svc=alice" }
|
||||
`;
|
||||
console.log(str);
|
||||
|
||||
mermaidAPI.parse(str);
|
||||
const actors = parser.yy.getActors();
|
||||
expect(actors.a.links["Repo"]).toBe("https://repo.contoso.com/");
|
||||
expect(actors.b.links["Repo"]).toBe(undefined);
|
||||
expect(actors.a.links["Dashboard"]).toBe("https://dashboard.contoso.com/");
|
||||
expect(actors.b.links["Dashboard"]).toBe("https://dashboard.contoso.com/");
|
||||
expect(actors.a.links["On-Call"]).toBe("https://oncall.contoso.com/?svc=alice");
|
||||
expect(actors.c.links["Dashboard"]).toBe(undefined);
|
||||
});
|
||||
|
||||
it('it should handle properties', function () {
|
||||
const str = `
|
||||
sequenceDiagram
|
||||
participant a as Alice
|
||||
participant b as Bob
|
||||
participant c as Charlie
|
||||
properties a: {"class": "internal-service-actor", "icon": "@clock"}
|
||||
properties b: {"class": "external-service-actor", "icon": "@computer"}
|
||||
`;
|
||||
console.log(str);
|
||||
|
||||
mermaidAPI.parse(str);
|
||||
const actors = parser.yy.getActors();
|
||||
expect(actors.a.properties["class"]).toBe("internal-service-actor");
|
||||
expect(actors.b.properties["class"]).toBe("external-service-actor");
|
||||
expect(actors.a.properties["icon"]).toBe("@clock");
|
||||
expect(actors.b.properties["icon"]).toBe("@computer");
|
||||
expect(actors.c.properties["class"]).toBe(undefined);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('when checking the bounds in a sequenceDiagram', function() {
|
||||
|
@@ -230,7 +230,9 @@ const drawNote = function(elem, noteModel) {
|
||||
let textElem = drawText(g, textObj);
|
||||
|
||||
let textHeight = Math.round(
|
||||
textElem.map(te => (te._groups || te)[0][0].getBBox().height).reduce((acc, curr) => acc + curr)
|
||||
textElem
|
||||
.map(te => (te._groups || te)[0][0].getBBox().height)
|
||||
.reduce((acc, curr) => acc + curr)
|
||||
);
|
||||
|
||||
rectElem.attr('height', textHeight + 2 * conf.noteMargin);
|
||||
@@ -253,7 +255,7 @@ const noteFont = cnf => {
|
||||
return {
|
||||
fontFamily: cnf.noteFontFamily,
|
||||
fontSize: cnf.noteFontSize,
|
||||
fontWeight: cnf.noteFontWeight
|
||||
fontWeight: cnf.noteFontWeight,
|
||||
};
|
||||
};
|
||||
const actorFont = cnf => {
|
||||
@@ -308,7 +310,7 @@ const drawMessage = function(g, msgModel) {
|
||||
.attr(
|
||||
'd',
|
||||
`M ${startx},${lineStarty} H ${startx +
|
||||
Math.max(conf.width / 2, textWidth / 2)} V ${lineStarty + 25} H ${startx}`
|
||||
Math.max(conf.width / 2, textWidth / 2)} V ${lineStarty + 25} H ${startx}`
|
||||
);
|
||||
} else {
|
||||
totalOffset += conf.boxMargin;
|
||||
@@ -443,6 +445,24 @@ export const drawActors = function(diagram, actors, actorKeys, verticalPos) {
|
||||
bounds.bumpVerticalPos(conf.height);
|
||||
};
|
||||
|
||||
export const drawActorsPopup = function(diagram, actors, actorKeys) {
|
||||
var maxHeight = 0;
|
||||
var maxWidth = 0;
|
||||
for (let i = 0; i < actorKeys.length; i++) {
|
||||
const actor = actors[actorKeys[i]];
|
||||
const minMenuWidth = getRequiredPopupWidth(actor);
|
||||
var menuDimensions = svgDraw.drawPopup(diagram, actor, minMenuWidth, conf, conf.forceMenus);
|
||||
if (menuDimensions.height > maxHeight) {
|
||||
maxHeight = menuDimensions.height;
|
||||
}
|
||||
if (menuDimensions.width + actor.x > maxWidth) {
|
||||
maxWidth = menuDimensions.width + actor.x;
|
||||
}
|
||||
}
|
||||
|
||||
return { maxHeight: maxHeight, maxWidth: maxWidth };
|
||||
};
|
||||
|
||||
export const setConf = function(cnf) {
|
||||
assignWithDepth(conf, cnf);
|
||||
|
||||
@@ -521,6 +541,10 @@ export const draw = function(text, id) {
|
||||
const maxMessageWidthPerActor = getMaxMessageWidthPerActor(actors, messages);
|
||||
conf.height = calculateActorMargins(actors, maxMessageWidthPerActor);
|
||||
|
||||
svgDraw.insertComputerIcon(diagram);
|
||||
svgDraw.insertDatabaseIcon(diagram);
|
||||
svgDraw.insertClockIcon(diagram);
|
||||
|
||||
drawActors(diagram, actors, actorKeys, 0);
|
||||
const loopWidths = calculateLoopBounds(messages, actors, maxMessageWidthPerActor);
|
||||
|
||||
@@ -687,6 +711,9 @@ export const draw = function(text, id) {
|
||||
drawActors(diagram, actors, actorKeys, bounds.getVerticalPos());
|
||||
}
|
||||
|
||||
// only draw popups for the top row of actors.
|
||||
var requiredBoxSize = drawActorsPopup(diagram, actors, actorKeys);
|
||||
|
||||
const { bounds: box } = bounds.getBounds();
|
||||
|
||||
// Adjust line height of actor lines now that the height of the diagram is known
|
||||
@@ -694,12 +721,23 @@ export const draw = function(text, id) {
|
||||
const actorLines = selectAll('#' + id + ' .actor-line');
|
||||
actorLines.attr('y2', box.stopy);
|
||||
|
||||
let height = box.stopy - box.starty + 2 * conf.diagramMarginY;
|
||||
// Make sure the height of the diagram supports long menus.
|
||||
let boxHeight = box.stopy - box.starty;
|
||||
if (boxHeight < requiredBoxSize.maxHeight) {
|
||||
boxHeight = requiredBoxSize.maxHeight;
|
||||
}
|
||||
|
||||
let height = boxHeight + 2 * conf.diagramMarginY;
|
||||
if (conf.mirrorActors) {
|
||||
height = height - conf.boxMargin + conf.bottomMarginAdj;
|
||||
}
|
||||
|
||||
const width = box.stopx - box.startx + 2 * conf.diagramMarginX;
|
||||
// Make sure the width of the diagram supports wide menus.
|
||||
let boxWidth = box.stopx - box.startx;
|
||||
if (boxWidth < requiredBoxSize.maxWidth) {
|
||||
boxWidth = requiredBoxSize.maxWidth;
|
||||
}
|
||||
const width = boxWidth + 2 * conf.diagramMarginX;
|
||||
|
||||
if (title) {
|
||||
diagram
|
||||
@@ -831,6 +869,20 @@ const getMaxMessageWidthPerActor = function(actors, messages) {
|
||||
return maxMessageWidthPerActor;
|
||||
};
|
||||
|
||||
const getRequiredPopupWidth = function(actor) {
|
||||
let requiredPopupWidth = 0;
|
||||
const textFont = actorFont(conf);
|
||||
for (let key in actor.links) {
|
||||
let labelDimensions = utils.calculateTextDimensions(key, textFont);
|
||||
let labelWidth = labelDimensions.width + 2 * conf.wrapPadding + 2 * conf.boxMargin;
|
||||
if (requiredPopupWidth < labelWidth) {
|
||||
requiredPopupWidth = labelWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return requiredPopupWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will calculate the optimal margin for each given actor, for a given
|
||||
* actor->messageWidth map.
|
||||
@@ -1111,6 +1163,7 @@ const calculateLoopBounds = function(messages, actors) {
|
||||
export default {
|
||||
bounds,
|
||||
drawActors,
|
||||
drawActorsPopup,
|
||||
setConf,
|
||||
draw
|
||||
};
|
||||
|
File diff suppressed because one or more lines are too long
@@ -94,3 +94,14 @@ text.actor > tspan {
|
||||
fill: $activationBkgColor;
|
||||
stroke: $activationBorderColor;
|
||||
}
|
||||
|
||||
.actorPopupMenu {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.actorPopupMenuPanel {
|
||||
position: absolute;
|
||||
fill: $actorBkg;
|
||||
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
|
||||
filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));
|
||||
}
|
||||
|
Reference in New Issue
Block a user