Merge pull request #2276 from eajenkins/feature/2249_sequence_diagram_popup_menus

Initial implementation for Issue#2249.
This commit is contained in:
Knut Sveidqvist 2021-09-18 13:28:29 +02:00 committed by GitHub
commit 1c3f94c9ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 620 additions and 8 deletions

1
.gitignore vendored
View File

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

View File

@ -562,6 +562,47 @@ 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"}
link a: Contacts @ https://contacts.contoso.com/?contact=alice@contoso.com
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

@ -441,6 +441,47 @@ 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 one or more link lines with the format:
link <actor>: <link-label> @ <link-url>
```
sequenceDiagram
participant Alice
participant John
link Alice: Dashboard @ https://dashboard.contoso.com/alice
link Alice: Wiki @ https://wiki.contoso.com/alice
link John: Dashboard @ https://dashboard.contoso.com/john
link John: Wiki @ https://wiki.contoso.com/john
Alice->>John: Hello John, how are you?
John-->>Alice: Great!
Alice-)John: See you later!
```
#### Advanced Menu Syntax
There is an advanced syntax that relies on JSON formatting. If you are comfortable with JSON format, then this exists as well.
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!
```
## 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

@ -366,6 +366,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

@ -48,13 +48,17 @@
"end" return 'end';
"left of" return 'left_of';
"right of" return 'right_of';
"links" return 'links';
"link" return 'link';
"properties" return 'properties';
"details" return 'details';
"over" return 'over';
"note" return 'note';
"activate" { this.begin('ID'); return 'activate'; }
"deactivate" { this.begin('ID'); return 'deactivate'; }
"title" return 'title';
"sequenceDiagram" return 'SD';
"autonumber" return 'autonumber';
"autonumber" return 'autonumber';
"," return ',';
";" return 'NEWLINE';
[^\+\->:\n,;]+((?!(\-x|\-\-x|\-\)|\-\-\)))[\-]*[^\+\->:\n,;]+)* { yytext = yytext.trim(); return 'ACTOR'; }
@ -64,8 +68,8 @@
"-->" return 'DOTTED_OPEN_ARROW';
\-[x] return 'SOLID_CROSS';
\-\-[x] return 'DOTTED_CROSS';
\-[\)] return 'SOLID_POINT';
\-\-[\)] return 'DOTTED_POINT';
\-[\)] return 'SOLID_POINT';
\-\-[\)] return 'DOTTED_POINT';
":"(?:(?:no)?wrap:)?[^#\n;]+ return 'TXT';
"+" return '+';
"-" return '-';
@ -113,6 +117,10 @@ 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'
| link_statement 'NEWLINE'
| properties_statement 'NEWLINE'
| details_statement 'NEWLINE'
| title text2 'NEWLINE' {$$=[{type:'setTitle', text:$2}]}
| 'loop' restOfLine document end
{
@ -173,6 +181,34 @@ 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}];
}
;
link_statement
: 'link' actor text2
{
$$ = [$2, {type:'addALink', 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

@ -33,6 +33,10 @@ export const addActor = function (id, name, description, type) {
description: description.text,
wrap: (description.wrap === undefined && autoWrap()) || !!description.wrap,
prevActor: prevActor,
links: {},
properties: {},
actorCnt: null,
rectData: null,
type: type || 'participant',
};
if (prevActor && actors[prevActor]) {
@ -210,6 +214,99 @@ 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);
}
};
export const addALink = function (actorId, text) {
// find the actor
const actor = getActor(actorId);
try {
const links = {};
var sep = text.text.indexOf('@');
var label = text.text.slice(0, sep - 1).trim();
var link = text.text.slice(sep + 1).trim();
links[label] = link;
// 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;
@ -237,6 +334,18 @@ export const apply = function (param) {
case 'addNote':
addNote(param.actor, param.placement, param.text);
break;
case 'addLinks':
addLinks(param.actor, param.text);
break;
case 'addALink':
addALink(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;
@ -287,6 +396,9 @@ export default {
addActor,
addMessage,
addSignal,
addLinks,
addDetails,
addProperties,
autoWrap,
setWrap,
enableSequenceNumbers,
@ -295,6 +407,7 @@ export default {
getActors,
getActor,
getActorKeys,
getActorProperty,
getTitle,
parseDirective,
getConfig: () => configApi.getConfig().sequence,

View File

@ -947,6 +947,55 @@ 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" }
link a: Endpoint @ https://alice.contoso.com
link a: Swagger @ https://swagger.contoso.com
link a: Tests @ https://tests.contoso.com/?svc=alice@contoso.com
`;
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);
expect(actors.a.links["Endpoint"]).toBe("https://alice.contoso.com");
expect(actors.a.links["Swagger"]).toBe("https://swagger.contoso.com");
expect(actors.a.links["Tests"]).toBe("https://tests.contoso.com/?svc=alice@contoso.com");
});
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

@ -447,6 +447,24 @@ export const drawActors = function (diagram, actors, actorKeys, verticalPos) {
bounds.bumpVerticalPos(maxHeight);
};
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);
@ -525,6 +543,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);
@ -693,6 +715,9 @@ export const draw = function (text, id) {
fixLifeLineHeights(diagram, 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
@ -700,12 +725,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
@ -837,6 +873,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.
@ -1117,6 +1167,7 @@ const calculateLoopBounds = function (messages, actors) {
export default {
bounds,
drawActors,
drawActorsPopup,
setConf,
draw,
};

View File

@ -95,6 +95,17 @@ const getStyles = (options) =>
fill: ${options.activationBkgColor};
stroke: ${options.activationBorderColor};
}
.actorPopupMenu {
position: absolute;
}
.actorPopupMenuPanel {
position: absolute;
fill: ${options.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));
}
.actor-man line {
stroke: ${options.actorBorder};
fill: ${options.actorBkg};

File diff suppressed because one or more lines are too long