From 2decf94ad044a2a44dae8f884cd3051b83ae05c4 Mon Sep 17 00:00:00 2001 From: Justin Greywolf Date: Mon, 30 Dec 2019 17:24:19 -0800 Subject: [PATCH] 1064- Add click functionality to class diagrams modified interaction functionality from flowcharts to work with class diagrams --- .../rendering/classDiagram.spec.js | 44 ++++++ src/diagrams/class/classDb.js | 147 +++++++++++++++++- src/diagrams/class/classDiagram.spec.js | 96 ++++++++++++ src/diagrams/class/classRenderer.js | 34 +++- src/diagrams/class/parser/classDiagram.jison | 11 ++ 5 files changed, 324 insertions(+), 8 deletions(-) diff --git a/cypress/integration/rendering/classDiagram.spec.js b/cypress/integration/rendering/classDiagram.spec.js index 3ca5d7f51..d7fe3cd57 100644 --- a/cypress/integration/rendering/classDiagram.spec.js +++ b/cypress/integration/rendering/classDiagram.spec.js @@ -228,4 +228,48 @@ describe('Class diagram', () => { ); cy.get('svg'); }); + + it('9: should render a simple class diagram with clickable link', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + link class01 "google.com" "A Tooltip" + `, + {} + ); + cy.get('svg'); + }); + + it('10: should render a simple class diagram with clickable callback', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + callback class01 "functionCall" "A Tooltip" + `, + {} + ); + cy.get('svg'); + }); }); diff --git a/src/diagrams/class/classDb.js b/src/diagrams/class/classDb.js index 113da1dee..4eb3641a0 100644 --- a/src/diagrams/class/classDb.js +++ b/src/diagrams/class/classDb.js @@ -1,8 +1,17 @@ +import * as d3 from 'd3'; +import { sanitizeUrl } from '@braintree/sanitize-url'; import { logger } from '../../logger'; +import { getConfig } from '../../config'; + +const MERMAID_DOM_ID_PREFIX = ''; + +const config = getConfig(); let relations = []; let classes = {}; +let funs = []; + const splitClassNameAndType = function(id) { let genericType = ''; let className = id; @@ -29,6 +38,7 @@ export const addClass = function(id) { classes[classId.className] = { id: classId.className, type: classId.type, + cssClasses: [], methods: [], members: [], annotations: [] @@ -38,6 +48,8 @@ export const addClass = function(id) { export const clear = function() { relations = []; classes = {}; + funs = []; + funs.push(setupToolTips); }; export const getClass = function(id) { @@ -117,6 +129,91 @@ export const cleanupLabel = function(label) { } }; +/** + * Called by parser when a special node is found, e.g. a clickable element. + * @param ids Comma separated list of ids + * @param className Class to add + */ +export const setCssClass = function(ids, className) { + ids.split(',').forEach(function(_id) { + let id = _id; + if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + if (typeof classes[id] !== 'undefined') { + classes[id].cssClasses.push(className); + } + }); +}; + +/** + * Called by parser when a link is found. Adds the URL to the vertex data. + * @param ids Comma separated list of ids + * @param linkStr URL to create a link for + * @param tooltip Tooltip for the clickable element + */ +export const setLink = function(ids, linkStr, tooltip) { + ids.split(',').forEach(function(_id) { + let id = _id; + if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + if (typeof classes[id] !== 'undefined') { + if (config.securityLevel !== 'loose') { + classes[id].link = sanitizeUrl(linkStr); + } else { + classes[id].link = linkStr; + } + + if (tooltip) { + classes[id].tooltip = tooltip; + } + } + }); + setCssClass(ids, 'clickable'); +}; + +/** + * Called by parser when a click definition is found. Registers an event handler. + * @param ids Comma separated list of ids + * @param functionName Function to be called on click + * @param tooltip Tooltip for the clickable element + */ +export const setClickEvent = function(ids, functionName, tooltip) { + ids.split(',').forEach(function(id) { + setClickFunc(id, functionName, tooltip); + }); + setCssClass(ids, 'clickable'); +}; + +const setClickFunc = function(_id, functionName) { + let id = _id; + if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + if (config.securityLevel !== 'loose') { + return; + } + if (typeof functionName === 'undefined') { + return; + } + if (typeof classes[id] !== 'undefined') { + funs.push(function() { + const elem = document.querySelector(`[id="${id}"]`); + if (elem !== null) { + elem.setAttribute('title', classes[id].tooltip); + elem.addEventListener( + 'click', + function() { + window[functionName](id); + }, + false + ); + } + }); + } +}; + +export const bindFunctions = function(element) { + funs.forEach(function(fun) { + fun(element); + }); +}; + export const lineType = { LINE: 0, DOTTED_LINE: 1 @@ -129,8 +226,53 @@ export const relationType = { DEPENDENCY: 3 }; +const setupToolTips = function(element) { + let tooltipElem = d3.select('.mermaidTooltip'); + if ((tooltipElem._groups || tooltipElem)[0][0] === null) { + tooltipElem = d3 + .select('body') + .append('div') + .attr('class', 'mermaidTooltip') + .style('opacity', 0); + } + + const svg = d3.select(element).select('svg'); + + const nodes = svg.selectAll('g.node'); + nodes + .on('mouseover', function() { + const el = d3.select(this); + const title = el.attr('title'); + // Dont try to draw a tooltip if no data is provided + if (title === null) { + return; + } + const rect = this.getBoundingClientRect(); + + tooltipElem + .transition() + .duration(200) + .style('opacity', '.9'); + tooltipElem + .html(el.attr('title')) + .style('left', rect.left + (rect.right - rect.left) / 2 + 'px') + .style('top', rect.top - 14 + document.body.scrollTop + 'px'); + el.classed('hover', true); + }) + .on('mouseout', function() { + tooltipElem + .transition() + .duration(500) + .style('opacity', 0); + const el = d3.select(this); + el.classed('hover', false); + }); +}; +funs.push(setupToolTips); + export default { addClass, + bindFunctions, clear, getClass, getClasses, @@ -141,5 +283,8 @@ export default { addMembers, cleanupLabel, lineType, - relationType + relationType, + setClickEvent, + setCssClass, + setLink }; diff --git a/src/diagrams/class/classDiagram.spec.js b/src/diagrams/class/classDiagram.spec.js index 3140a41a7..e5ef55d7a 100644 --- a/src/diagrams/class/classDiagram.spec.js +++ b/src/diagrams/class/classDiagram.spec.js @@ -250,6 +250,66 @@ describe('class diagram, ', function () { parser.parse(str); }); + + it('should handle click statement with link', function () { + const str = + 'classDiagram\n' + + 'class Class1 {\n' + + '%% Comment Class01 <|-- Class02\n' + + 'int : test\n' + + 'string : foo\n' + + 'test()\n' + + 'foo()\n' + + '}\n' + + 'link Class01 "google.com" '; + + parser.parse(str); + }); + + it('should handle click statement with link and tooltip', function () { + const str = + 'classDiagram\n' + + 'class Class1 {\n' + + '%% Comment Class01 <|-- Class02\n' + + 'int : test\n' + + 'string : foo\n' + + 'test()\n' + + 'foo()\n' + + '}\n' + + 'link Class01 "google.com" "A Tooltip" '; + + parser.parse(str); + }); + + it('should handle click statement with callback', function () { + const str = + 'classDiagram\n' + + 'class Class1 {\n' + + '%% Comment Class01 <|-- Class02\n' + + 'int : test\n' + + 'string : foo\n' + + 'test()\n' + + 'foo()\n' + + '}\n' + + 'callback Class01 "functionCall" '; + + parser.parse(str); + }); + + it('should handle click statement with callback and tooltip', function () { + const str = + 'classDiagram\n' + + 'class Class1 {\n' + + '%% Comment Class01 <|-- Class02\n' + + 'int : test\n' + + 'string : foo\n' + + 'test()\n' + + 'foo()\n' + + '}\n' + + 'callback Class01 "functionCall" "A Tooltip" '; + + parser.parse(str); + }); }); describe('when fetching data from a classDiagram graph it', function () { @@ -464,5 +524,41 @@ describe('class diagram, ', function () { expect(testClass.methods.length).toBe(1); expect(testClass.methods[0]).toBe('someMethod()$'); }); + + it('should associate link and css appropriately', function () { + const str = 'classDiagram\n' + 'class Class1\n' + 'Class1 : someMethod()\n' + 'link Class1 "google.com"'; + parser.parse(str); + + const testClass = parser.yy.getClass('Class1'); + expect(testClass.link).toBe('about:blank');//('google.com'); security needs to be set to 'loose' for this to work right + expect(testClass.cssClasses.length).toBe(1); + expect(testClass.cssClasses[0]).toBe('clickable'); + }); + it('should associate link with tooltip', function () { + const str = 'classDiagram\n' + 'class Class1\n' + 'Class1 : someMethod()\n' + 'link Class1 "google.com" "A tooltip"'; + parser.parse(str); + + const testClass = parser.yy.getClass('Class1'); + expect(testClass.link).toBe('about:blank');//('google.com'); security needs to be set to 'loose' for this to work right + expect(testClass.tooltip).toBe('A tooltip'); + expect(testClass.cssClasses.length).toBe(1); + expect(testClass.cssClasses[0]).toBe('clickable'); + }); + + it('should associate callback appropriately', function () { + spyOn(classDb, 'setClickEvent'); + const str = 'classDiagram\n' + 'class Class1\n' + 'Class1 : someMethod()\n' + 'callback Class1 "functionCall"'; + parser.parse(str); + + expect(classDb.setClickEvent).toHaveBeenCalledWith('Class1', 'functionCall', undefined); + }); + + it('should associate callback with tooltip', function () { + spyOn(classDb, 'setClickEvent'); + const str = 'classDiagram\n' + 'class Class1\n' + 'Class1 : someMethod()\n' + 'callback Class1 "functionCall" "A tooltip"'; + parser.parse(str); + + expect(classDb.setClickEvent).toHaveBeenCalledWith('Class1', 'functionCall', 'A tooltip'); + }); }); }); diff --git a/src/diagrams/class/classRenderer.js b/src/diagrams/class/classRenderer.js index 58f78ac34..b319ba628 100644 --- a/src/diagrams/class/classRenderer.js +++ b/src/diagrams/class/classRenderer.js @@ -280,6 +280,11 @@ const drawEdge = function(elem, path, relation) { const drawClass = function(elem, classDef) { logger.info('Rendering class ' + classDef); + let cssClassStr = 'classGroup '; + if (classDef.cssClasses.length > 0) { + cssClassStr = cssClassStr + classDef.cssClasses.join(' '); + } + const addTspan = function(textEl, txt, isFirst) { let displayText = txt; let cssStyle = ''; @@ -326,13 +331,26 @@ const drawClass = function(elem, classDef) { const g = elem .append('g') .attr('id', id) - .attr('class', 'classGroup'); + .attr('class', cssClassStr); // add title - const title = g - .append('text') - .attr('y', conf.textHeight + conf.padding) - .attr('x', 0); + let title; + if (classDef.link) { + title = g + .append("svg:a") + .attr("xlink:href", classDef.link) + .attr('xlink:target', '_blank') + .attr('xlink:title', classDef.tooltip) + .append('text') + .attr('y', conf.textHeight + conf.padding) + .attr('x', 0); + } + else { + title = g + .append('text') + .attr('y', conf.textHeight + conf.padding) + .attr('x', 0); + } // add annotations let isFirst = true; @@ -348,12 +366,11 @@ const drawClass = function(elem, classDef) { classTitleString += '<' + classDef.type + '>'; } - // add class title const classTitle = title .append('tspan') .text(classTitleString) .attr('class', 'title'); - + // If class has annotations the title needs to have an offset of the text height if (!isFirst) classTitle.attr('dy', conf.textHeight); @@ -434,6 +451,7 @@ export const setConf = function(cnf) { conf[key] = cnf[key]; }); }; + /** * Draws a flowchart in the tag with id: id based on the graph definition in text. * @param text @@ -470,10 +488,12 @@ export const draw = function(text, id) { for (let i = 0; i < keys.length; i++) { const classDef = classes[keys[i]]; const node = drawClass(diagram, classDef); + // Add nodes to the graph. The first argument is the node id. The second is // metadata about the node. In this case we're going to add labels to each of // our nodes. g.setNode(node.id, node); + logger.info('Org height: ' + node.height); } diff --git a/src/diagrams/class/parser/classDiagram.jison b/src/diagrams/class/parser/classDiagram.jison index 1c1b8e669..7bd768138 100644 --- a/src/diagrams/class/parser/classDiagram.jison +++ b/src/diagrams/class/parser/classDiagram.jison @@ -21,6 +21,9 @@ "class" return 'CLASS'; +//"click" return 'CLICK'; +"callback" return 'CALLBACK'; +"link" return 'LINK'; "<<" return 'ANNOTATION_START'; ">>" return 'ANNOTATION_END'; [~] this.begin("generic"); @@ -149,6 +152,7 @@ statement | classStatement | methodStatement | annotationStatement + | clickStatement ; classStatement @@ -198,6 +202,13 @@ lineType | DOTTED_LINE {$$=yy.lineType.DOTTED_LINE;} ; +clickStatement + : CALLBACK className STR {$$ = $1;yy.setClickEvent($2, $3, undefined);} + | CALLBACK className STR STR {$$ = $1;yy.setClickEvent($2, $3, $4);} + | LINK className STR {$$ = $1;yy.setLink($2, $3, undefined);} + | LINK className STR STR {$$ = $1;yy.setLink($2, $3, $4);} + ; + commentToken : textToken | graphCodeTokens ; textToken : textNoTagsToken | TAGSTART | TAGEND | '==' | '--' | PCT | DEFAULT;