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:
eajenkins
2021-08-27 10:56:56 -07:00
parent 2fd7992b0e
commit 291bec7e90
10 changed files with 590 additions and 16 deletions

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ local/
_site
Gemfile.lock
/.vs

View File

@@ -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(

View File

@@ -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

View File

@@ -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|
*| --- | --- | --- | --- | --- |

View File

@@ -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

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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

View File

@@ -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));
}