Merge pull request #2276 from eajenkins/feature/2249_sequence_diagram_popup_menus
Initial implementation for Issue#2249.
This commit is contained in:
commit
1c3f94c9ec
|
@ -24,3 +24,4 @@ local/
|
|||
|
||||
_site
|
||||
Gemfile.lock
|
||||
/.vs
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
||||
* | --- | --- | --- | --- | --- |
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue