mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-22 17:56:43 +02:00
User journey handler
This commit is contained in:
23
README.md
23
README.md
@@ -16,6 +16,10 @@ For more information and help in getting started, please view our [documentation
|
||||
|
||||
:trophy: **Mermaid was nominated and won the [JS Open Source Awards (2019)](https://osawards.com/javascript/#nominees) in the category "The most exciting use of technology"!!! Thanks to all involved, people committing pull requests, people answering questions and special thanks to Tyler Long who is helping me maintain the project.**
|
||||
|
||||
## New diagram
|
||||
|
||||
This version comes with a new diagram type, user journey diagrams.
|
||||
|
||||
## New diagrams in 8.4
|
||||
|
||||
With version 8.4 class diagrams have got some new features, bug fixes and documentation. Another new feature in 8.4 is the new diagram type, state diagrams.
|
||||
@@ -182,6 +186,25 @@ pie
|
||||
<td colspan="2" align="center"><i>Coming soon!</i></td>
|
||||
</tr>
|
||||
<!-- </Git> -->
|
||||
<!-- <Journey> -->
|
||||
<tr>
|
||||
<td><pre>
|
||||
journey
|
||||
title Hello
|
||||
section Go to work
|
||||
Make tea: 5: Me
|
||||
Go upstairs: 3: Me
|
||||
Do work: 1: Me, Cat
|
||||
section Go home
|
||||
Go downstairs: 5: Me
|
||||
Sit down: 3: Me
|
||||
</pre></td>
|
||||
<td align="center">
|
||||
<img alt="User Journey Diagram" src="https://raw.githubusercontent.com/mermaid-js/mermaid/master/img/gray-journey.png" />
|
||||
</td>
|
||||
</tr>
|
||||
<!-- </Journey> -->
|
||||
|
||||
</table>
|
||||
|
||||
## Related projects
|
||||
|
33
cypress/integration/rendering/journey.spec.js
Normal file
33
cypress/integration/rendering/journey.spec.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-env jest */
|
||||
import { imgSnapshotTest } from '../../helpers/util.js';
|
||||
|
||||
describe('User journey diagram', () => {
|
||||
it('Simple test', () => {
|
||||
imgSnapshotTest(
|
||||
`journey
|
||||
title Adding journey diagram functionality to mermaid
|
||||
section Order from website
|
||||
`,
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a user journey chart', () => {
|
||||
imgSnapshotTest(
|
||||
`
|
||||
journey
|
||||
title Go shopping
|
||||
|
||||
section Get to the shops
|
||||
Get car keys: Dad
|
||||
Get into car: Dad, Mum, Child#1, Child#2
|
||||
Drive to supermarket: Dad
|
||||
|
||||
section Do shopping
|
||||
Do actual shop: Mum
|
||||
Get in the way: Dad, Child#1, Child#2
|
||||
`,
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
41
cypress/platform/user-journey.html
Normal file
41
cypress/platform/user-journey.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<html>
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Montserrat&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<h1>User Journey</h1>
|
||||
<div class="mermaid">
|
||||
journey
|
||||
title Go shopping
|
||||
|
||||
section Get to the shops
|
||||
Get car keys:5: Dad
|
||||
Get into car:5: Dad, Mum, Child 1, Child 2
|
||||
Really drive to supermarket:3: Dad
|
||||
|
||||
section Do shopping
|
||||
Do actual shop:3: Mum
|
||||
Get in the way:2: Dad, Child 1, Child 2
|
||||
Pay: 2: Dad
|
||||
|
||||
section Go home
|
||||
Lose keys:3: Dad
|
||||
Get cross:1: Dad, Child 1
|
||||
Find keys:4: Mum
|
||||
Get into car:4: Dad, Mum, Child 1, Child 2
|
||||
Drive home:3: Dad
|
||||
</div>
|
||||
<script src="./mermaid.js"></script>
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
theme: 'forest',
|
||||
logLevel: 1,
|
||||
journey: { taskMargin: 30 },
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -263,6 +263,119 @@ The number of alternating section styles.
|
||||
Datetime format of the axis. This might need adjustment to match your locale and preferences
|
||||
**Default value '%Y-%m-%d'**.
|
||||
|
||||
## journey
|
||||
|
||||
The object containing configurations specific for sequence diagrams
|
||||
|
||||
### diagramMarginX
|
||||
|
||||
margin to the right and left of the sequence diagram.
|
||||
**Default value 50**.
|
||||
|
||||
### diagramMarginY
|
||||
|
||||
margin to the over and under the sequence diagram.
|
||||
**Default value 10**.
|
||||
|
||||
### actorMargin
|
||||
|
||||
Margin between actors.
|
||||
**Default value 50**.
|
||||
|
||||
### width
|
||||
|
||||
Width of actor boxes
|
||||
**Default value 150**.
|
||||
|
||||
### height
|
||||
|
||||
Height of actor boxes
|
||||
**Default value 65**.
|
||||
|
||||
### boxMargin
|
||||
|
||||
Margin around loop boxes
|
||||
**Default value 10**.
|
||||
|
||||
### boxTextMargin
|
||||
|
||||
margin around the text in loop/alt/opt boxes
|
||||
**Default value 5**.
|
||||
|
||||
### noteMargin
|
||||
|
||||
margin around notes.
|
||||
**Default value 10**.
|
||||
|
||||
### messageMargin
|
||||
|
||||
Space between messages.
|
||||
**Default value 35**.
|
||||
|
||||
### messageAlign
|
||||
|
||||
Multiline message alignment. Possible values are:
|
||||
|
||||
- left
|
||||
- center **default**
|
||||
- right
|
||||
|
||||
### bottomMarginAdj
|
||||
|
||||
Depending on css styling this might need adjustment.
|
||||
Prolongs the edge of the diagram downwards.
|
||||
**Default value 1**.
|
||||
|
||||
### useMaxWidth
|
||||
|
||||
when this flag is set the height and width is set to 100% and is then scaling with the
|
||||
available space if not the absolute space required is used.
|
||||
**Default value true**.
|
||||
|
||||
### rightAngles
|
||||
|
||||
This will display arrows that start and begin at the same node as right angles, rather than a curve
|
||||
**Default value false**.
|
||||
|
||||
## er
|
||||
|
||||
The object containing configurations specific for entity relationship diagrams
|
||||
|
||||
### layoutDirection
|
||||
|
||||
Directional bias for layout of entities. Can be either 'TB', 'BT', 'LR', or 'RL',
|
||||
where T = top, B = bottom, L = left, and R = right.
|
||||
|
||||
### minEntityWidth
|
||||
|
||||
The mimimum width of an entity box
|
||||
|
||||
### minEntityHeight
|
||||
|
||||
The minimum height of an entity box
|
||||
|
||||
### entityPadding
|
||||
|
||||
The minimum internal padding between the text in an entity box and the enclosing box borders
|
||||
|
||||
### stroke
|
||||
|
||||
Stroke color of box edges and lines
|
||||
|
||||
### fill
|
||||
|
||||
Fill color of entity boxes
|
||||
|
||||
### fillOpacity
|
||||
|
||||
Opacity of entity boxes - if you want to see how the crows feet
|
||||
retain their elegant joins to the boxes regardless of the angle of incidence
|
||||
then override this to something less than 100%
|
||||
|
||||
### fontSize
|
||||
|
||||
Font size
|
||||
|
||||
## render
|
||||
|
||||
Function that renders an svg with a graph from a chart definition. Usage example below.
|
||||
|
BIN
img/gray-journey.png
Normal file
BIN
img/gray-journey.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
@@ -27,7 +27,8 @@
|
||||
"test": "yarn lint && jest src/.*",
|
||||
"test:watch": "jest --watch src",
|
||||
"prepublishOnly": "yarn build && yarn release && yarn test && yarn e2e",
|
||||
"prepush": "yarn test"
|
||||
"prepush": "yarn test",
|
||||
"prepare": "yarn build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
123
src/diagrams/user-journey/journeyDb.js
Normal file
123
src/diagrams/user-journey/journeyDb.js
Normal file
@@ -0,0 +1,123 @@
|
||||
let title = '';
|
||||
let currentSection = '';
|
||||
|
||||
const sections = [];
|
||||
const tasks = [];
|
||||
const rawTasks = [];
|
||||
|
||||
export const clear = function() {
|
||||
sections.length = 0;
|
||||
tasks.length = 0;
|
||||
currentSection = '';
|
||||
title = '';
|
||||
rawTasks.length = 0;
|
||||
};
|
||||
|
||||
export const setTitle = function(txt) {
|
||||
title = txt;
|
||||
};
|
||||
|
||||
export const getTitle = function() {
|
||||
return title;
|
||||
};
|
||||
|
||||
export const addSection = function(txt) {
|
||||
currentSection = txt;
|
||||
sections.push(txt);
|
||||
};
|
||||
|
||||
export const getSections = function() {
|
||||
return sections;
|
||||
};
|
||||
|
||||
export const getTasks = function() {
|
||||
let allItemsProcessed = compileTasks();
|
||||
const maxDepth = 100;
|
||||
let iterationCount = 0;
|
||||
while (!allItemsProcessed && iterationCount < maxDepth) {
|
||||
allItemsProcessed = compileTasks();
|
||||
iterationCount++;
|
||||
}
|
||||
|
||||
tasks.push(...rawTasks);
|
||||
|
||||
return tasks;
|
||||
};
|
||||
|
||||
const updateActors = function() {
|
||||
const tempActors = [];
|
||||
tasks.forEach(task => {
|
||||
if (task.people) {
|
||||
tempActors.push(...task.people);
|
||||
}
|
||||
});
|
||||
|
||||
const unique = new Set(tempActors);
|
||||
return [...unique].sort();
|
||||
};
|
||||
|
||||
export const addTask = function(descr, taskData) {
|
||||
const pieces = taskData.substr(1).split(':');
|
||||
|
||||
let score = 0;
|
||||
let peeps = [];
|
||||
if (pieces.length === 1) {
|
||||
score = Number(pieces[0]);
|
||||
peeps = [];
|
||||
} else {
|
||||
score = Number(pieces[0]);
|
||||
peeps = pieces[1].split(',');
|
||||
}
|
||||
const peopleList = peeps.map(s => s.trim());
|
||||
|
||||
const rawTask = {
|
||||
section: currentSection,
|
||||
type: currentSection,
|
||||
people: peopleList,
|
||||
task: descr,
|
||||
score
|
||||
};
|
||||
|
||||
rawTasks.push(rawTask);
|
||||
};
|
||||
|
||||
export const addTaskOrg = function(descr) {
|
||||
const newTask = {
|
||||
section: currentSection,
|
||||
type: currentSection,
|
||||
description: descr,
|
||||
task: descr,
|
||||
classes: []
|
||||
};
|
||||
tasks.push(newTask);
|
||||
};
|
||||
|
||||
const compileTasks = function() {
|
||||
const compileTask = function(pos) {
|
||||
return rawTasks[pos].processed;
|
||||
};
|
||||
|
||||
let allProcessed = true;
|
||||
for (let i = 0; i < rawTasks.length; i++) {
|
||||
compileTask(i);
|
||||
|
||||
allProcessed = allProcessed && rawTasks[i].processed;
|
||||
}
|
||||
return allProcessed;
|
||||
};
|
||||
|
||||
const getActors = function() {
|
||||
return updateActors();
|
||||
};
|
||||
|
||||
export default {
|
||||
clear,
|
||||
setTitle,
|
||||
getTitle,
|
||||
addSection,
|
||||
getSections,
|
||||
getTasks,
|
||||
addTask,
|
||||
addTaskOrg,
|
||||
getActors
|
||||
};
|
90
src/diagrams/user-journey/journeyDb.spec.js
Normal file
90
src/diagrams/user-journey/journeyDb.spec.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-env jasmine */
|
||||
import journeyDb from './journeyDb';
|
||||
|
||||
describe('when using the journeyDb', function() {
|
||||
beforeEach(function() {
|
||||
journeyDb.clear();
|
||||
});
|
||||
|
||||
describe('when calling the clear function', function() {
|
||||
beforeEach(function() {
|
||||
journeyDb.addSection('weekends skip test');
|
||||
journeyDb.addTask('test1', '4: id1, id3');
|
||||
journeyDb.addTask('test2', '2: id2');
|
||||
journeyDb.clear();
|
||||
});
|
||||
|
||||
it.each`
|
||||
fn | expected
|
||||
${'getTasks'} | ${[]}
|
||||
${'getTitle'} | ${''}
|
||||
${'getSections'} | ${[]}
|
||||
${'getActors'} | ${[]}
|
||||
`('should clear $fn', ({ fn, expected }) => {
|
||||
expect(journeyDb[fn]()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when calling the clear function', function() {
|
||||
beforeEach(function() {
|
||||
journeyDb.addSection('weekends skip test');
|
||||
journeyDb.addTask('test1', '3: id1, id3');
|
||||
journeyDb.addTask('test2', '1: id2');
|
||||
journeyDb.clear();
|
||||
});
|
||||
|
||||
it.each`
|
||||
fn | expected
|
||||
${'getTasks'} | ${[]}
|
||||
${'getTitle'} | ${''}
|
||||
${'getSections'} | ${[]}
|
||||
`('should clear $fn', ({ fn, expected }) => {
|
||||
expect(journeyDb[fn]()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks and actors should be added', function() {
|
||||
journeyDb.setTitle('Shopping');
|
||||
journeyDb.addSection('Journey to the shops');
|
||||
journeyDb.addTask('Get car keys', ':5:Dad');
|
||||
journeyDb.addTask('Go to car', ':3:Dad, Mum, Child#1, Child#2');
|
||||
journeyDb.addTask('Drive to supermarket', ':4:Dad');
|
||||
journeyDb.addSection('Do shopping');
|
||||
journeyDb.addTask('Go shopping', ':5:Mum');
|
||||
|
||||
expect(journeyDb.getTitle()).toEqual('Shopping');
|
||||
expect(journeyDb.getTasks()).toEqual([
|
||||
{
|
||||
score: 5,
|
||||
people: ['Dad'],
|
||||
section: 'Journey to the shops',
|
||||
task: 'Get car keys',
|
||||
type: 'Journey to the shops'
|
||||
},
|
||||
{
|
||||
score: 3,
|
||||
people: ['Dad', 'Mum', 'Child#1', 'Child#2'],
|
||||
section: 'Journey to the shops',
|
||||
task: 'Go to car',
|
||||
type: 'Journey to the shops'
|
||||
},
|
||||
{
|
||||
score: 4,
|
||||
people: ['Dad'],
|
||||
section: 'Journey to the shops',
|
||||
task: 'Drive to supermarket',
|
||||
type: 'Journey to the shops'
|
||||
},
|
||||
{
|
||||
score: 5,
|
||||
people: ['Mum'],
|
||||
section: 'Do shopping',
|
||||
task: 'Go shopping',
|
||||
type: 'Do shopping'
|
||||
}
|
||||
]);
|
||||
expect(journeyDb.getActors()).toEqual(['Child#1', 'Child#2', 'Dad', 'Mum']);
|
||||
|
||||
expect(journeyDb.getSections()).toEqual(['Journey to the shops', 'Do shopping']);
|
||||
});
|
||||
});
|
284
src/diagrams/user-journey/journeyRenderer.js
Normal file
284
src/diagrams/user-journey/journeyRenderer.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import * as d3 from 'd3';
|
||||
|
||||
import { parser } from './parser/journey';
|
||||
import journeyDb from './journeyDb';
|
||||
import svgDraw from './svgDraw';
|
||||
|
||||
parser.yy = journeyDb;
|
||||
|
||||
const conf = {
|
||||
leftMargin: 150,
|
||||
diagramMarginX: 50,
|
||||
diagramMarginY: 20,
|
||||
// Margin between tasks
|
||||
taskMargin: 50,
|
||||
// Width of task boxes
|
||||
width: 150,
|
||||
// Height of task boxes
|
||||
height: 50,
|
||||
taskFontSize: 14,
|
||||
taskFontFamily: '"Open-Sans", "sans-serif"',
|
||||
// Margin around loop boxes
|
||||
boxMargin: 10,
|
||||
boxTextMargin: 5,
|
||||
noteMargin: 10,
|
||||
// Space between messages
|
||||
messageMargin: 35,
|
||||
// Multiline message alignment
|
||||
messageAlign: 'center',
|
||||
// Depending on css styling this might need adjustment
|
||||
// Projects the edge of the diagram downwards
|
||||
bottomMarginAdj: 1,
|
||||
|
||||
// width of activation box
|
||||
activationWidth: 10,
|
||||
|
||||
// text placement as: tspan | fo | old only text as before
|
||||
textPlacement: 'fo',
|
||||
|
||||
actorColours: ['#8FBC8F', '#7CFC00', '#00FFFF', '#20B2AA', '#B0E0E6', '#FFFFE0'],
|
||||
|
||||
sectionFills: ['#191970', '#8B008B', '#4B0082', '#2F4F4F', '#800000', '#8B4513', '#00008B'],
|
||||
sectionColours: ['#fff']
|
||||
};
|
||||
|
||||
export const setConf = function(cnf) {
|
||||
const keys = Object.keys(cnf);
|
||||
|
||||
keys.forEach(function(key) {
|
||||
conf[key] = cnf[key];
|
||||
});
|
||||
};
|
||||
|
||||
const actors = {};
|
||||
|
||||
function drawActorLegend(diagram) {
|
||||
// Draw the actors
|
||||
let yPos = 60;
|
||||
Object.keys(actors).forEach(person => {
|
||||
const colour = actors[person];
|
||||
|
||||
const circleData = {
|
||||
cx: 20,
|
||||
cy: yPos,
|
||||
r: 7,
|
||||
fill: colour,
|
||||
stroke: '#000'
|
||||
};
|
||||
svgDraw.drawCircle(diagram, circleData);
|
||||
|
||||
const labelData = {
|
||||
x: 40,
|
||||
y: yPos + 7,
|
||||
fill: '#666',
|
||||
text: person
|
||||
};
|
||||
svgDraw.drawText(diagram, labelData);
|
||||
|
||||
yPos += 20;
|
||||
});
|
||||
}
|
||||
|
||||
const LEFT_MARGIN = conf.leftMargin;
|
||||
export const draw = function(text, id) {
|
||||
parser.yy.clear();
|
||||
parser.parse(text + '\n');
|
||||
|
||||
bounds.init();
|
||||
const diagram = d3.select('#' + id);
|
||||
diagram.attr('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
svgDraw.initGraphics(diagram);
|
||||
|
||||
const tasks = parser.yy.getTasks();
|
||||
const title = parser.yy.getTitle();
|
||||
|
||||
const actorNames = parser.yy.getActors();
|
||||
for (let member in actors) delete actors[member];
|
||||
let actorPos = 0;
|
||||
actorNames.forEach(actorName => {
|
||||
actors[actorName] = conf.actorColours[actorPos % conf.actorColours.length];
|
||||
actorPos++;
|
||||
});
|
||||
|
||||
drawActorLegend(diagram);
|
||||
bounds.insert(0, 0, LEFT_MARGIN, Object.keys(actors).length * 50);
|
||||
|
||||
drawTasks(diagram, tasks, 0);
|
||||
|
||||
const box = bounds.getBounds();
|
||||
if (title) {
|
||||
diagram
|
||||
.append('text')
|
||||
.text(title)
|
||||
.attr('x', LEFT_MARGIN)
|
||||
.attr('font-size', '4ex')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('y', 25);
|
||||
}
|
||||
const height = box.stopy - box.starty + 2 * conf.diagramMarginY;
|
||||
const width = LEFT_MARGIN + box.stopx + 2 * conf.diagramMarginX;
|
||||
if (conf.useMaxWidth) {
|
||||
diagram.attr('height', '100%');
|
||||
diagram.attr('width', '100%');
|
||||
diagram.attr('style', 'max-width:' + width + 'px;');
|
||||
} else {
|
||||
diagram.attr('height', height);
|
||||
diagram.attr('width', width);
|
||||
}
|
||||
|
||||
// Draw activity line
|
||||
diagram
|
||||
.append('line')
|
||||
.attr('x1', LEFT_MARGIN)
|
||||
.attr('y1', conf.height * 4) // One section head + one task + margins
|
||||
.attr('x2', width - LEFT_MARGIN - 4) // Subtract stroke width so arrow point is retained
|
||||
.attr('y2', conf.height * 4)
|
||||
.attr('stroke-width', 4)
|
||||
.attr('stroke', 'black')
|
||||
.attr('marker-end', 'url(#arrowhead)');
|
||||
|
||||
const extraVertForTitle = title ? 70 : 0;
|
||||
diagram.attr('viewBox', `${box.startx} -25 ${width} ${height + extraVertForTitle}`);
|
||||
diagram.attr('preserveAspectRatio', 'xMinYMin meet');
|
||||
};
|
||||
|
||||
export const bounds = {
|
||||
data: {
|
||||
startx: undefined,
|
||||
stopx: undefined,
|
||||
starty: undefined,
|
||||
stopy: undefined
|
||||
},
|
||||
verticalPos: 0,
|
||||
|
||||
sequenceItems: [],
|
||||
init: function() {
|
||||
this.sequenceItems = [];
|
||||
this.data = {
|
||||
startx: undefined,
|
||||
stopx: undefined,
|
||||
starty: undefined,
|
||||
stopy: undefined
|
||||
};
|
||||
this.verticalPos = 0;
|
||||
},
|
||||
updateVal: function(obj, key, val, fun) {
|
||||
if (typeof obj[key] === 'undefined') {
|
||||
obj[key] = val;
|
||||
} else {
|
||||
obj[key] = fun(val, obj[key]);
|
||||
}
|
||||
},
|
||||
updateBounds: function(startx, starty, stopx, stopy) {
|
||||
const _self = this;
|
||||
let cnt = 0;
|
||||
function updateFn(type) {
|
||||
return function updateItemBounds(item) {
|
||||
cnt++;
|
||||
// The loop sequenceItems is a stack so the biggest margins in the beginning of the sequenceItems
|
||||
const n = _self.sequenceItems.length - cnt + 1;
|
||||
|
||||
_self.updateVal(item, 'starty', starty - n * conf.boxMargin, Math.min);
|
||||
_self.updateVal(item, 'stopy', stopy + n * conf.boxMargin, Math.max);
|
||||
|
||||
_self.updateVal(bounds.data, 'startx', startx - n * conf.boxMargin, Math.min);
|
||||
_self.updateVal(bounds.data, 'stopx', stopx + n * conf.boxMargin, Math.max);
|
||||
|
||||
if (!(type === 'activation')) {
|
||||
_self.updateVal(item, 'startx', startx - n * conf.boxMargin, Math.min);
|
||||
_self.updateVal(item, 'stopx', stopx + n * conf.boxMargin, Math.max);
|
||||
|
||||
_self.updateVal(bounds.data, 'starty', starty - n * conf.boxMargin, Math.min);
|
||||
_self.updateVal(bounds.data, 'stopy', stopy + n * conf.boxMargin, Math.max);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.sequenceItems.forEach(updateFn());
|
||||
},
|
||||
insert: function(startx, starty, stopx, stopy) {
|
||||
const _startx = Math.min(startx, stopx);
|
||||
const _stopx = Math.max(startx, stopx);
|
||||
const _starty = Math.min(starty, stopy);
|
||||
const _stopy = Math.max(starty, stopy);
|
||||
|
||||
this.updateVal(bounds.data, 'startx', _startx, Math.min);
|
||||
this.updateVal(bounds.data, 'starty', _starty, Math.min);
|
||||
this.updateVal(bounds.data, 'stopx', _stopx, Math.max);
|
||||
this.updateVal(bounds.data, 'stopy', _stopy, Math.max);
|
||||
|
||||
this.updateBounds(_startx, _starty, _stopx, _stopy);
|
||||
},
|
||||
bumpVerticalPos: function(bump) {
|
||||
this.verticalPos = this.verticalPos + bump;
|
||||
this.data.stopy = this.verticalPos;
|
||||
},
|
||||
getVerticalPos: function() {
|
||||
return this.verticalPos;
|
||||
},
|
||||
getBounds: function() {
|
||||
return this.data;
|
||||
}
|
||||
};
|
||||
|
||||
const fills = conf.sectionFills;
|
||||
const textColours = conf.sectionColours;
|
||||
|
||||
export const drawTasks = function(diagram, tasks, verticalPos) {
|
||||
let lastSection = '';
|
||||
const sectionVHeight = conf.height * 2 + conf.diagramMarginY;
|
||||
const taskPos = verticalPos + sectionVHeight;
|
||||
|
||||
let sectionNumber = 0;
|
||||
let fill = '#CCC';
|
||||
let colour = 'black';
|
||||
|
||||
// Draw the tasks
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
let task = tasks[i];
|
||||
if (lastSection !== task.section) {
|
||||
fill = fills[sectionNumber % fills.length];
|
||||
colour = textColours[sectionNumber % textColours.length];
|
||||
|
||||
const section = {
|
||||
x: i * conf.taskMargin + i * conf.width + LEFT_MARGIN,
|
||||
y: 50,
|
||||
text: task.section,
|
||||
fill,
|
||||
colour
|
||||
};
|
||||
|
||||
svgDraw.drawSection(diagram, section, conf);
|
||||
lastSection = task.section;
|
||||
sectionNumber++;
|
||||
}
|
||||
|
||||
// Collect the actors involved in the task
|
||||
const taskActors = task.people.reduce((acc, actorName) => {
|
||||
if (actors[actorName]) {
|
||||
acc[actorName] = actors[actorName];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Add some rendering data to the object
|
||||
task.x = i * conf.taskMargin + i * conf.width + LEFT_MARGIN;
|
||||
task.y = taskPos;
|
||||
task.width = conf.diagramMarginX;
|
||||
task.height = conf.diagramMarginY;
|
||||
task.colour = colour;
|
||||
task.fill = fill;
|
||||
task.actors = taskActors;
|
||||
|
||||
// Draw the box with the attached line
|
||||
svgDraw.drawTask(diagram, task, conf);
|
||||
bounds.insert(task.x, task.y, task.x + task.width + conf.taskMargin, 300 + 5 * 30); // stopy is the length of the descenders.
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
setConf,
|
||||
draw
|
||||
};
|
52
src/diagrams/user-journey/parser/journey.jison
Normal file
52
src/diagrams/user-journey/parser/journey.jison
Normal file
@@ -0,0 +1,52 @@
|
||||
/** mermaid
|
||||
* https://mermaidjs.github.io/
|
||||
* (c) 2015 Knut Sveidqvist
|
||||
* MIT license.
|
||||
*/
|
||||
%lex
|
||||
%options case-insensitive
|
||||
%%
|
||||
|
||||
[\n]+ return 'NL';
|
||||
\s+ /* skip whitespace */
|
||||
\#[^\n]* /* skip comments */
|
||||
\%%[^\n]* /* skip comments */
|
||||
|
||||
"journey" return 'journey';
|
||||
"title"\s[^#\n;]+ return 'title';
|
||||
"section"\s[^#:\n;]+ return 'section';
|
||||
[^#:\n;]+ return 'taskName';
|
||||
":"[^#\n;]+ return 'taskData';
|
||||
":" return ':';
|
||||
<<EOF>> return 'EOF';
|
||||
. return 'INVALID';
|
||||
|
||||
/lex
|
||||
|
||||
%left '^'
|
||||
|
||||
%start start
|
||||
|
||||
%% /* language grammar */
|
||||
|
||||
start
|
||||
: journey document 'EOF' { return $2; }
|
||||
;
|
||||
|
||||
document
|
||||
: /* empty */ { $$ = [] }
|
||||
| document line {$1.push($2);$$ = $1}
|
||||
;
|
||||
|
||||
line
|
||||
: SPACE statement { $$ = $2 }
|
||||
| statement { $$ = $1 }
|
||||
| NL { $$=[];}
|
||||
| EOF { $$=[];}
|
||||
;
|
||||
|
||||
statement
|
||||
: title {yy.setTitle($1.substr(6));$$=$1.substr(6);}
|
||||
| section {yy.addSection($1.substr(8));$$=$1.substr(8);}
|
||||
| taskName taskData {yy.addTask($1, $2);$$='task';}
|
||||
;
|
117
src/diagrams/user-journey/parser/journey.spec.js
Normal file
117
src/diagrams/user-journey/parser/journey.spec.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/* eslint-env jasmine */
|
||||
/* eslint-disable no-eval */
|
||||
import { parser } from './journey';
|
||||
import journeyDb from '../journeyDb';
|
||||
|
||||
const parserFnConstructor = str => {
|
||||
return () => {
|
||||
parser.parse(str);
|
||||
};
|
||||
};
|
||||
|
||||
describe('when parsing a journey diagram it', function() {
|
||||
beforeEach(function() {
|
||||
parser.yy = journeyDb;
|
||||
parser.yy.clear();
|
||||
});
|
||||
|
||||
it('should handle a title definition', function() {
|
||||
const str = 'journey\ntitle Adding journey diagram functionality to mermaid';
|
||||
|
||||
expect(parserFnConstructor(str)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle a section definition', function() {
|
||||
const str =
|
||||
'journey\n' +
|
||||
'title Adding journey diagram functionality to mermaid\n' +
|
||||
'section Order from website';
|
||||
|
||||
expect(parserFnConstructor(str)).not.toThrow();
|
||||
});
|
||||
it('should handle multiline section titles with different line breaks', function() {
|
||||
const str =
|
||||
'journey\n' +
|
||||
'title Adding gantt diagram functionality to mermaid\n' +
|
||||
'section Line1<br>Line2<br/>Line3</br />Line4<br\t/>Line5';
|
||||
|
||||
expect(parserFnConstructor(str)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle a task definition', function() {
|
||||
const str =
|
||||
'journey\n' +
|
||||
'title Adding journey diagram functionality to mermaid\n' +
|
||||
'section Documentation\n' +
|
||||
'A task: 5: Alice, Bob, Charlie\n' +
|
||||
'B task: 3:Bob, Charlie\n' +
|
||||
'C task: 5\n' +
|
||||
'D task: 5: Charlie, Alice\n' +
|
||||
'E task: 5:\n' +
|
||||
'section Another section\n' +
|
||||
'P task: 5:\n' +
|
||||
'Q task: 5:\n' +
|
||||
'R task: 5:';
|
||||
expect(parserFnConstructor(str)).not.toThrow();
|
||||
|
||||
const tasks = parser.yy.getTasks();
|
||||
expect(tasks.length).toEqual(8);
|
||||
|
||||
expect(tasks[0]).toEqual({
|
||||
score: 5,
|
||||
people: ['Alice', 'Bob', 'Charlie'],
|
||||
section: 'Documentation',
|
||||
task: 'A task',
|
||||
type: 'Documentation'
|
||||
});
|
||||
expect(tasks[1]).toEqual({
|
||||
score: 3,
|
||||
people: ['Bob', 'Charlie'],
|
||||
section: 'Documentation',
|
||||
type: 'Documentation',
|
||||
task: 'B task'
|
||||
});
|
||||
expect(tasks[2]).toEqual({
|
||||
score: 5,
|
||||
people: [],
|
||||
section: 'Documentation',
|
||||
type: 'Documentation',
|
||||
task: 'C task'
|
||||
});
|
||||
expect(tasks[3]).toEqual({
|
||||
score: 5,
|
||||
people: ['Charlie', 'Alice'],
|
||||
section: 'Documentation',
|
||||
task: 'D task',
|
||||
type: 'Documentation'
|
||||
});
|
||||
expect(tasks[4]).toEqual({
|
||||
score: 5,
|
||||
people: [''],
|
||||
section: 'Documentation',
|
||||
type: 'Documentation',
|
||||
task: 'E task'
|
||||
});
|
||||
expect(tasks[5]).toEqual({
|
||||
score: 5,
|
||||
people: [''],
|
||||
section: 'Another section',
|
||||
type: 'Another section',
|
||||
task: 'P task'
|
||||
});
|
||||
expect(tasks[6]).toEqual({
|
||||
score: 5,
|
||||
people: [''],
|
||||
section: 'Another section',
|
||||
type: 'Another section',
|
||||
task: 'Q task'
|
||||
});
|
||||
expect(tasks[7]).toEqual({
|
||||
score: 5,
|
||||
people: [''],
|
||||
section: 'Another section',
|
||||
type: 'Another section',
|
||||
task: 'R task'
|
||||
});
|
||||
});
|
||||
});
|
429
src/diagrams/user-journey/svgDraw.js
Normal file
429
src/diagrams/user-journey/svgDraw.js
Normal file
@@ -0,0 +1,429 @@
|
||||
import * as d3 from 'd3';
|
||||
|
||||
export const drawRect = function(elem, rectData) {
|
||||
const rectElem = elem.append('rect');
|
||||
rectElem.attr('x', rectData.x);
|
||||
rectElem.attr('y', rectData.y);
|
||||
rectElem.attr('fill', rectData.fill);
|
||||
rectElem.attr('stroke', rectData.stroke);
|
||||
rectElem.attr('width', rectData.width);
|
||||
rectElem.attr('height', rectData.height);
|
||||
rectElem.attr('rx', rectData.rx);
|
||||
rectElem.attr('ry', rectData.ry);
|
||||
|
||||
if (typeof rectData.class !== 'undefined') {
|
||||
rectElem.attr('class', rectData.class);
|
||||
}
|
||||
|
||||
return rectElem;
|
||||
};
|
||||
|
||||
export const drawFace = function(element, faceData) {
|
||||
const radius = 15;
|
||||
const circleElement = element
|
||||
.append('circle')
|
||||
.attr('cx', faceData.cx)
|
||||
.attr('cy', faceData.cy)
|
||||
.attr('fill', '#FFF8DC')
|
||||
.attr('stroke', '#999')
|
||||
.attr('r', radius)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('overflow', 'visible');
|
||||
|
||||
const face = element.append('g');
|
||||
|
||||
//left eye
|
||||
face
|
||||
.append('circle')
|
||||
.attr('cx', faceData.cx - radius / 3)
|
||||
.attr('cy', faceData.cy - radius / 3)
|
||||
.attr('r', 1.5)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('fill', '#666')
|
||||
.attr('stroke', '#666');
|
||||
|
||||
//right eye
|
||||
face
|
||||
.append('circle')
|
||||
.attr('cx', faceData.cx + radius / 3)
|
||||
.attr('cy', faceData.cy - radius / 3)
|
||||
.attr('r', 1.5)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('fill', '#666')
|
||||
.attr('stroke', '#666');
|
||||
|
||||
function smile(face) {
|
||||
const arc = d3
|
||||
.arc()
|
||||
.startAngle(Math.PI / 2)
|
||||
.endAngle(3 * (Math.PI / 2))
|
||||
.innerRadius(radius / 2)
|
||||
.outerRadius(radius / 2.2);
|
||||
//mouth
|
||||
face
|
||||
.append('path')
|
||||
.attr('d', arc)
|
||||
.attr('transform', 'translate(' + faceData.cx + ',' + (faceData.cy + 2) + ')');
|
||||
}
|
||||
|
||||
function sad(face) {
|
||||
const arc = d3
|
||||
.arc()
|
||||
.startAngle((3 * Math.PI) / 2)
|
||||
.endAngle(5 * (Math.PI / 2))
|
||||
.innerRadius(radius / 2)
|
||||
.outerRadius(radius / 2.2);
|
||||
//mouth
|
||||
face
|
||||
.append('path')
|
||||
.attr('d', arc)
|
||||
.attr('transform', 'translate(' + faceData.cx + ',' + (faceData.cy + 7) + ')');
|
||||
}
|
||||
|
||||
function ambivalent(face) {
|
||||
face
|
||||
.append('line')
|
||||
.attr('stroke', 2)
|
||||
.attr('x1', faceData.cx - 5)
|
||||
.attr('y1', faceData.cy + 7)
|
||||
.attr('x2', faceData.cx + 5)
|
||||
.attr('y2', faceData.cy + 7)
|
||||
.attr('class', 'task-line')
|
||||
.attr('stroke-width', '1px')
|
||||
.attr('stroke', '#666');
|
||||
}
|
||||
|
||||
if (faceData.score > 3) {
|
||||
smile(face);
|
||||
} else if (faceData.score < 3) {
|
||||
sad(face);
|
||||
} else {
|
||||
ambivalent(face);
|
||||
}
|
||||
|
||||
return circleElement;
|
||||
};
|
||||
|
||||
export const drawCircle = function(element, circleData) {
|
||||
const circleElement = element.append('circle');
|
||||
circleElement.attr('cx', circleData.cx);
|
||||
circleElement.attr('cy', circleData.cy);
|
||||
circleElement.attr('fill', circleData.fill);
|
||||
circleElement.attr('stroke', circleData.stroke);
|
||||
circleElement.attr('r', circleData.r);
|
||||
|
||||
if (typeof circleElement.class !== 'undefined') {
|
||||
circleElement.attr('class', circleElement.class);
|
||||
}
|
||||
|
||||
if (typeof circleData.title !== 'undefined') {
|
||||
circleElement.append('title').text(circleData.title);
|
||||
}
|
||||
|
||||
return circleElement;
|
||||
};
|
||||
|
||||
export const drawText = function(elem, textData) {
|
||||
// Remove and ignore br:s
|
||||
const nText = textData.text.replace(/<br\s*\/?>/gi, ' ');
|
||||
|
||||
const textElem = elem.append('text');
|
||||
textElem.attr('x', textData.x);
|
||||
textElem.attr('y', textData.y);
|
||||
textElem.attr('fill', textData.fill);
|
||||
textElem.style('text-anchor', textData.anchor);
|
||||
|
||||
if (typeof textData.class !== 'undefined') {
|
||||
textElem.attr('class', textData.class);
|
||||
}
|
||||
|
||||
const span = textElem.append('tspan');
|
||||
span.attr('x', textData.x + textData.textMargin * 2);
|
||||
span.text(nText);
|
||||
|
||||
return textElem;
|
||||
};
|
||||
|
||||
export const drawLabel = function(elem, txtObject) {
|
||||
function genPoints(x, y, width, height, cut) {
|
||||
return (
|
||||
x +
|
||||
',' +
|
||||
y +
|
||||
' ' +
|
||||
(x + width) +
|
||||
',' +
|
||||
y +
|
||||
' ' +
|
||||
(x + width) +
|
||||
',' +
|
||||
(y + height - cut) +
|
||||
' ' +
|
||||
(x + width - cut * 1.2) +
|
||||
',' +
|
||||
(y + height) +
|
||||
' ' +
|
||||
x +
|
||||
',' +
|
||||
(y + height)
|
||||
);
|
||||
}
|
||||
const polygon = elem.append('polygon');
|
||||
polygon.attr('points', genPoints(txtObject.x, txtObject.y, 50, 20, 7));
|
||||
polygon.attr('class', 'labelBox');
|
||||
|
||||
txtObject.y = txtObject.y + txtObject.labelMargin;
|
||||
txtObject.x = txtObject.x + 0.5 * txtObject.labelMargin;
|
||||
drawText(elem, txtObject);
|
||||
};
|
||||
|
||||
export const drawSection = function(elem, section, conf) {
|
||||
const g = elem.append('g');
|
||||
|
||||
const rect = getNoteRect();
|
||||
rect.x = section.x;
|
||||
rect.y = section.y;
|
||||
rect.fill = section.fill;
|
||||
rect.width = conf.width;
|
||||
rect.height = conf.height;
|
||||
rect.class = 'journey-section';
|
||||
rect.rx = 3;
|
||||
rect.ry = 3;
|
||||
drawRect(g, rect);
|
||||
|
||||
_drawTextCandidateFunc(conf)(
|
||||
section.text,
|
||||
g,
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.width,
|
||||
rect.height,
|
||||
{ class: 'journey-section' },
|
||||
conf,
|
||||
section.colour
|
||||
);
|
||||
};
|
||||
|
||||
let taskCount = -1;
|
||||
/**
|
||||
* Draws an actor in the diagram with the attaced line
|
||||
* @param elem The HTML element
|
||||
* @param task The task to render
|
||||
* @param conf The global configuration
|
||||
*/
|
||||
export const drawTask = function(elem, task, conf) {
|
||||
const center = task.x + conf.width / 2;
|
||||
const g = elem.append('g');
|
||||
taskCount++;
|
||||
const maxHeight = 300 + 5 * 30;
|
||||
g.append('line')
|
||||
.attr('id', 'task' + taskCount)
|
||||
.attr('x1', center)
|
||||
.attr('y1', task.y)
|
||||
.attr('x2', center)
|
||||
.attr('y2', maxHeight)
|
||||
.attr('class', 'task-line')
|
||||
.attr('stroke-width', '1px')
|
||||
.attr('stroke-dasharray', '4 2')
|
||||
.attr('stroke', '#666');
|
||||
|
||||
drawFace(g, {
|
||||
cx: center,
|
||||
cy: 300 + (5 - task.score) * 30,
|
||||
score: task.score
|
||||
});
|
||||
|
||||
const rect = getNoteRect();
|
||||
rect.x = task.x;
|
||||
rect.y = task.y;
|
||||
rect.fill = task.fill;
|
||||
rect.width = conf.width;
|
||||
rect.height = conf.height;
|
||||
rect.class = 'task';
|
||||
rect.rx = 3;
|
||||
rect.ry = 3;
|
||||
drawRect(g, rect);
|
||||
|
||||
let xPos = task.x + 14;
|
||||
task.people.forEach(person => {
|
||||
const colour = task.actors[person];
|
||||
|
||||
const circle = {
|
||||
cx: xPos,
|
||||
cy: task.y,
|
||||
r: 7,
|
||||
fill: colour,
|
||||
stroke: '#000',
|
||||
title: person
|
||||
};
|
||||
|
||||
drawCircle(g, circle);
|
||||
xPos += 10;
|
||||
});
|
||||
|
||||
_drawTextCandidateFunc(conf)(
|
||||
task.task,
|
||||
g,
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.width,
|
||||
rect.height,
|
||||
{ class: 'task' },
|
||||
conf,
|
||||
task.colour
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draws a background rectangle
|
||||
* @param elem The html element
|
||||
* @param bounds The bounds of the drawing
|
||||
*/
|
||||
export const drawBackgroundRect = function(elem, bounds) {
|
||||
const rectElem = drawRect(elem, {
|
||||
x: bounds.startx,
|
||||
y: bounds.starty,
|
||||
width: bounds.stopx - bounds.startx,
|
||||
height: bounds.stopy - bounds.starty,
|
||||
fill: bounds.fill,
|
||||
class: 'rect'
|
||||
});
|
||||
rectElem.lower();
|
||||
};
|
||||
|
||||
export const getTextObj = function() {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
fill: undefined,
|
||||
'text-anchor': 'start',
|
||||
width: 100,
|
||||
height: 100,
|
||||
textMargin: 0,
|
||||
rx: 0,
|
||||
ry: 0
|
||||
};
|
||||
};
|
||||
|
||||
export const getNoteRect = function() {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
anchor: 'start',
|
||||
height: 100,
|
||||
rx: 0,
|
||||
ry: 0
|
||||
};
|
||||
};
|
||||
|
||||
const _drawTextCandidateFunc = (function() {
|
||||
function byText(content, g, x, y, width, height, textAttrs, colour) {
|
||||
const text = g
|
||||
.append('text')
|
||||
.attr('x', x + width / 2)
|
||||
.attr('y', y + height / 2 + 5)
|
||||
.style('font-color', colour)
|
||||
.style('text-anchor', 'middle')
|
||||
.text(content);
|
||||
_setTextAttrs(text, textAttrs);
|
||||
}
|
||||
|
||||
function byTspan(content, g, x, y, width, height, textAttrs, conf, colour) {
|
||||
const { taskFontSize, taskFontFamily } = conf;
|
||||
|
||||
const lines = content.split(/<br\s*\/?>/gi);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const dy = i * taskFontSize - (taskFontSize * (lines.length - 1)) / 2;
|
||||
const text = g
|
||||
.append('text')
|
||||
.attr('x', x + width / 2)
|
||||
.attr('y', y)
|
||||
.attr('fill', colour)
|
||||
.style('text-anchor', 'middle')
|
||||
.style('font-size', taskFontSize)
|
||||
.style('font-family', taskFontFamily);
|
||||
text
|
||||
.append('tspan')
|
||||
.attr('x', x + width / 2)
|
||||
.attr('dy', dy)
|
||||
.text(lines[i]);
|
||||
|
||||
text
|
||||
.attr('y', y + height / 2.0)
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('alignment-baseline', 'central');
|
||||
|
||||
_setTextAttrs(text, textAttrs);
|
||||
}
|
||||
}
|
||||
|
||||
function byFo(content, g, x, y, width, height, textAttrs, conf, colour) {
|
||||
const body = g.append('switch');
|
||||
const f = body
|
||||
.append('foreignObject')
|
||||
.attr('x', x)
|
||||
.attr('y', y)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('position', 'fixed');
|
||||
|
||||
const text = f
|
||||
.append('div')
|
||||
.style('display', 'table')
|
||||
.style('height', '100%')
|
||||
.style('width', '100%');
|
||||
|
||||
text
|
||||
.append('div')
|
||||
.style('display', 'table-cell')
|
||||
.style('text-align', 'center')
|
||||
.style('vertical-align', 'middle')
|
||||
.style('color', colour)
|
||||
.text(content);
|
||||
|
||||
byTspan(content, body, x, y, width, height, textAttrs, conf);
|
||||
_setTextAttrs(text, textAttrs);
|
||||
}
|
||||
|
||||
function _setTextAttrs(toText, fromTextAttrsDict) {
|
||||
for (const key in fromTextAttrsDict) {
|
||||
if (key in fromTextAttrsDict) {
|
||||
// eslint-disable-line
|
||||
// noinspection JSUnfilteredForInLoop
|
||||
toText.attr(key, fromTextAttrsDict[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return function(conf) {
|
||||
return conf.textPlacement === 'fo' ? byFo : conf.textPlacement === 'old' ? byText : byTspan;
|
||||
};
|
||||
})();
|
||||
|
||||
const initGraphics = function(graphics) {
|
||||
graphics
|
||||
.append('defs')
|
||||
.append('marker')
|
||||
.attr('id', 'arrowhead')
|
||||
.attr('refX', 5)
|
||||
.attr('refY', 2)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 4)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M 0,0 V 4 L6,2 Z'); // this is actual shape for arrowhead
|
||||
};
|
||||
|
||||
export default {
|
||||
drawRect,
|
||||
drawCircle,
|
||||
drawSection,
|
||||
drawText,
|
||||
drawLabel,
|
||||
drawTask,
|
||||
drawBackgroundRect,
|
||||
getTextObj,
|
||||
getNoteRect,
|
||||
initGraphics
|
||||
};
|
Reference in New Issue
Block a user