diff --git a/packages/mermaid/src/diagrams/state/stateDb.js b/packages/mermaid/src/diagrams/state/stateDb.js index 96f92af8a..1fa0ac98a 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.js +++ b/packages/mermaid/src/diagrams/state/stateDb.js @@ -11,8 +11,50 @@ import { clear as commonClear, } from '../../commonDb'; -const clone = (o) => JSON.parse(JSON.stringify(o)); +const DEFAULT_DIRECTION = 'TB'; +const START_NODE = '[*]'; +const START_TYPE = 'start'; +const END_NODE = START_NODE; +const END_TYPE = 'end'; +const DEFAULT_TYPE = 'default'; + +const COLOR_KEYWORD = 'color'; +const FILL_KEYWORD = 'fill'; +const BG_FILL = 'bgFill'; +const STYLECLASS_SEP = ','; + +let direction = DEFAULT_DIRECTION; let rootDoc = []; +let classes = []; // style classes defined by a classDef + +const newDoc = () => { + return { + relations: [], + states: {}, + documents: {}, + }; +}; +let documents = { + root: newDoc(), +}; + +let currentDocument = documents.root; +let startEndCount = 0; +let dividerCnt = 0; + +export const lineType = { + LINE: 0, + DOTTED_LINE: 1, +}; + +export const relationType = { + AGGREGATION: 0, + EXTENSION: 1, + COMPOSITION: 2, + DEPENDENCY: 3, +}; + +const clone = (o) => JSON.parse(JSON.stringify(o)); export const parseDirective = function (statement, context, type) { mermaidAPI.parseDirective(this, statement, context, type); @@ -41,8 +83,8 @@ const docTranslator = (parent, node, first) => { if (node.doc) { const doc = []; // Check for concurrency - let i = 0; let currentDoc = []; + let i; for (i = 0; i < node.doc.length; i++) { if (node.doc[i].type === 'divider') { // debugger; @@ -77,6 +119,17 @@ const getRootDocV2 = () => { // Here }; +/** + * Convert all of the statements (stmts) that were parsed into states and relationships. + * This is done because a state diagram may have nested sections, + * where each section is a 'document' and has its own set of statements. + * Ex: the section within a fork has its own statements, and incoming and outgoing statements + * refer to the fork as a whole (document). + * See the parser grammar: the definition of a document is a document then a 'line', where a line can be a statement. + * This will push the statement into the the list of statements for the current document. + * + * @param _doc + */ const extract = (_doc) => { // const res = { states: [], relations: [] }; let doc; @@ -95,48 +148,66 @@ const extract = (_doc) => { log.info('Extract', doc); doc.forEach((item) => { - if (item.stmt === 'state') { - addState(item.id, item.type, item.doc, item.description, item.note); - } - if (item.stmt === 'relation') { - addRelation(item.state1.id, item.state2.id, item.description); + switch (item.stmt) { + case 'state': + addState( + item.id, + item.type, + item.doc, + item.description, + item.note, + item.classes, + item.styles, + item.textStyles + ); + break; + case 'relation': + addRelation(item.state1, item.state2, item.description); + break; + case 'classDef': + addStyleClass(item.id, item.classes); + break; + case 'applyClass': + setCssClass(item.id, item.styleClass); + break; } }); }; -const newDoc = () => { - return { - relations: [], - states: {}, - documents: {}, - }; -}; - -let documents = { - root: newDoc(), -}; - -let currentDocument = documents.root; - -let startCnt = 0; - /** * Function called by parser when a node definition has been found. * - * @param {any} id - * @param {any} type - * @param {any} doc - * @param {any} descr - * @param {any} note + * @param {null | string} id + * @param {null | string} type + * @param {null | string} doc + * @param {null | string | string[]} descr - description for the state. Can be a string or a list or strings + * @param {null | string} note + * @param {null | string | string[]} classes - class styles to apply to this state. Can be a string (1 style) or an array of styles. If it's just 1 class, convert it to an array of that 1 class. + * @param {null | string | string[]} styles - styles to apply to this state. Can be a string (1 style) or an array of styles. If it's just 1 style, convert it to an array of that 1 style. + * @param {null | string | string[]} textStyles - text styles to apply to this state. Can be a string (1 text test) or an array of text styles. If it's just 1 text style, convert it to an array of that 1 text style. */ -export const addState = function (id, type, doc, descr, note) { +export const addState = function ( + id, + type = DEFAULT_TYPE, + doc = null, + descr = null, + note = null, + classes = null, + styles = null, + textStyles = null +) { + // add the state if needed if (typeof currentDocument.states[id] === 'undefined') { + log.info('Adding state ', id, descr); currentDocument.states[id] = { id: id, descriptions: [], type, doc, note, + classes: [], + styles: [], + textStyles: [], }; } else { if (!currentDocument.states[id].doc) { @@ -146,8 +217,9 @@ export const addState = function (id, type, doc, descr, note) { currentDocument.states[id].type = type; } } + if (descr) { - log.info('Adding state ', id, descr); + log.info('Setting state description', id, descr); if (typeof descr === 'string') addDescription(id, descr.trim()); if (typeof descr === 'object') { @@ -162,6 +234,24 @@ export const addState = function (id, type, doc, descr, note) { configApi.getConfig() ); } + + if (classes) { + log.info('Setting state classes', id, classes); + const classesList = typeof classes === 'string' ? [classes] : classes; + classesList.forEach((klass) => setCssClass(id, klass.trim())); + } + + if (styles) { + log.info('Setting state styles', id, styles); + const stylesList = typeof styles === 'string' ? [styles] : styles; + stylesList.forEach((style) => setStyle(id, style.trim())); + } + + if (textStyles) { + log.info('Setting state styles', id, styles); + const textStylesList = typeof textStyles === 'string' ? [textStyles] : textStyles; + textStylesList.forEach((textStyle) => setTextStyle(id, textStyle.trim())); + } }; export const clear = function (saveCommon) { @@ -172,7 +262,8 @@ export const clear = function (saveCommon) { currentDocument = documents.root; - startCnt = 0; + // number of start and end nodes; used to construct ids + startEndCount = 0; classes = []; if (!saveCommon) { commonClear(); @@ -193,36 +284,134 @@ export const getRelations = function () { return currentDocument.relations; }; -export const addRelation = function (_id1, _id2, title) { - let id1 = _id1; - let id2 = _id2; - let type1 = 'default'; - let type2 = 'default'; - if (_id1 === '[*]') { - startCnt++; - id1 = 'start' + startCnt; - type1 = 'start'; +/** + * If the id is a start node ( [*] ), then return a new id constructed from + * the start node name and the current start node count. + * else return the given id + * + * @param {string} id + * @returns {{id: string, type: string}} - the id and type that should be used + */ +function startIdIfNeeded(id = '') { + let fixedId = id; + if (id === START_NODE) { + startEndCount++; + fixedId = `${START_TYPE}${startEndCount}`; } - if (_id2 === '[*]') { - id2 = 'end' + startCnt; - type2 = 'end'; + return fixedId; +} + +/** + * If the id is a start node ( [*] ), then return the start type ('start') + * else return the given type + * + * @param {string} id + * @param {string} type + * @returns {string} - the type that should be used + */ +function startTypeIfNeeded(id = '', type = DEFAULT_TYPE) { + return id === START_NODE ? START_TYPE : type; +} + +/** + * If the id is an end node ( [*] ), then return a new id constructed from + * the end node name and the current start_end node count. + * else return the given id + * + * @param {string} id + * @returns {{id: string, type: string}} - the id and type that should be used + */ +function endIdIfNeeded(id = '') { + let fixedId = id; + if (id === END_NODE) { + startEndCount++; + fixedId = `${END_TYPE}${startEndCount}`; } - addState(id1, type1); - addState(id2, type2); + return fixedId; +} + +/** + * If the id is an end node ( [*] ), then return the end type + * else return the given type + * + * @param {string} id + * @param {string} type + * @returns {string} - the type that should be used + */ +function endTypeIfNeeded(id = '', type = DEFAULT_TYPE) { + return id === END_NODE ? END_TYPE : type; +} + +/** + * + * @param item1 + * @param item2 + * @param relationTitle + */ +export function addRelationObjs(item1, item2, relationTitle) { + let id1 = startIdIfNeeded(item1.id); + let type1 = startTypeIfNeeded(item1.id, item1.type); + let id2 = startIdIfNeeded(item2.id); + let type2 = startTypeIfNeeded(item2.id, item2.type); + + addState( + id1, + type1, + item1.doc, + item1.description, + item1.note, + item1.classes, + item1.styles, + item1.textStyles + ); + addState( + id2, + type2, + item2.doc, + item2.description, + item2.note, + item2.classes, + item2.styles, + item2.textStyles + ); + currentDocument.relations.push({ id1, id2, - title: common.sanitizeText(title, configApi.getConfig()), + relationTitle: common.sanitizeText(relationTitle, configApi.getConfig()), }); +} + +/** + * Add a relation between two items. The items may be full objects or just the string id of a state. + * + * @param {string | object} item1 + * @param {string | object} item2 + * @param {string} title + */ +export const addRelation = function (item1, item2, title) { + if (typeof item1 === 'object') { + addRelationObjs(item1, item2, title); + } else { + const id1 = startIdIfNeeded(item1); + const type1 = startTypeIfNeeded(item1); + const id2 = endIdIfNeeded(item2); + const type2 = endTypeIfNeeded(item2); + + addState(id1, type1); + addState(id2, type2); + currentDocument.relations.push({ + id1, + id2, + title: common.sanitizeText(title, configApi.getConfig()), + }); + } }; -const addDescription = function (id, _descr) { +export const addDescription = function (id, descr) { const theState = currentDocument.states[id]; - let descr = _descr; - if (descr[0] === ':') { - descr = descr.substr(1).trim(); - } - theState.descriptions.push(common.sanitizeText(descr, configApi.getConfig())); + const _descr = descr.startsWith(':') ? descr.replace(':', '').trim() : descr; + theState.descriptions.push(common.sanitizeText(_descr, configApi.getConfig())); }; export const cleanupLabel = function (label) { @@ -233,34 +422,105 @@ export const cleanupLabel = function (label) { } }; -export const lineType = { - LINE: 0, - DOTTED_LINE: 1, -}; - -let dividerCnt = 0; const getDividerId = () => { dividerCnt++; return 'divider-id-' + dividerCnt; }; -let classes = []; +/** + * Called when the parser comes across a (style) class definition + * @example classDef someclass fill:#f96; + * + * @param {string} id - the id of this (style) class + * @param {string} styleAttributes - the string with 1 or more style attributes (each separated by a comma) + */ +export const addStyleClass = function (id, styleAttributes = '') { + // create a new style class object with this id + if (typeof classes[id] === 'undefined') { + classes[id] = { id: id, styles: [], textStyles: [] }; + } + const foundClass = classes[id]; + if (typeof styleAttributes !== 'undefined') { + if (styleAttributes !== null) { + styleAttributes.split(STYLECLASS_SEP).forEach((attrib) => { + // remove any trailing ; + const fixedAttrib = attrib.replace(/([^;]*);/, '$1').trim(); -const getClasses = () => classes; + // replace some style keywords + if (attrib.match(COLOR_KEYWORD)) { + const newStyle1 = fixedAttrib.replace(FILL_KEYWORD, BG_FILL); + const newStyle2 = newStyle1.replace(COLOR_KEYWORD, FILL_KEYWORD); + foundClass.textStyles.push(newStyle2); + } + foundClass.styles.push(fixedAttrib); + }); + } + } +}; + +/** + * Return all of the style classes + * @returns {{} | any | classes} + */ +export const getClasses = function () { + return classes; +}; + +/** + * Add a (style) class or css class to a state with the given id. + * If the state isn't already in the list of known states, add it. + * Might be called by parser when a style class or CSS class should be applied to a state + * + * @param {string | string[]} itemIds The id or a list of ids of the item(s) to apply the css class to + * @param {string} cssClassName CSS class name + */ +export const setCssClass = function (itemIds, cssClassName) { + itemIds.split(',').forEach(function (id) { + let foundState = getState(id); + if (typeof foundState === 'undefined') { + const trimmedId = id.trim(); + addState(trimmedId); + foundState = getState(trimmedId); + } + foundState.classes.push(cssClassName); + }); +}; + +/** + * Add a style to a state with the given id. + * @example style stateId fill:#f9f,stroke:#333,stroke-width:4px + * where 'style' is the keyword + * stateId is the id of a state + * the rest of the string is the styleText (all of the attributes to be applied to the state) + * + * @param itemId The id of item to apply the style to + * @param styleText - the text of the attributes for the style + */ +export const setStyle = function (itemId, styleText) { + const item = getState(itemId); + if (typeof item !== 'undefined') { + item.textStyles.push(styleText); + } +}; + +/** + * Add a text style to a state with the given id + * + * @param itemId The id of item to apply the css class to + * @param cssClassName CSS class name + */ +export const setTextStyle = function (itemId, cssClassName) { + const item = getState(itemId); + if (typeof item !== 'undefined') { + item.textStyles.push(cssClassName); + } +}; -let direction = 'TB'; const getDirection = () => direction; const setDirection = (dir) => { direction = dir; }; -export const relationType = { - AGGREGATION: 0, - EXTENSION: 1, - COMPOSITION: 2, - DEPENDENCY: 3, -}; - const trimColon = (str) => (str && str[0] === ':' ? str.substr(1).trim() : str.trim()); export default { @@ -289,4 +549,7 @@ export default { setAccTitle, getAccDescription, setAccDescription, + addStyleClass, + setCssClass, + addDescription, }; diff --git a/packages/mermaid/src/diagrams/state/stateDb.spec.js b/packages/mermaid/src/diagrams/state/stateDb.spec.js index 786c122aa..d51d919c3 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.spec.js +++ b/packages/mermaid/src/diagrams/state/stateDb.spec.js @@ -1,6 +1,10 @@ import stateDb from './stateDb'; -describe('stateDb', () => { +describe('State Diagram stateDb', () => { + beforeEach(() => { + stateDb.clear(); + }); + describe('addStyleClass', () => { it('is added to the list of style classes', () => { const newStyleClassId = 'newStyleClass'; @@ -14,4 +18,58 @@ describe('stateDb', () => { expect(styleClasses[newStyleClassId].styles[1]).toEqual('border:blue'); }); }); + + describe('addDescription to a state', () => { + beforeEach(() => { + stateDb.clear(); + stateDb.addState('state1'); + }); + + const testStateId = 'state1'; + + it('removes only the first leading :', () => { + const restOfTheDescription = 'rest of the description'; + const oneLeadingColon = `:${restOfTheDescription}`; + const twoLeadingColons = `::${restOfTheDescription}`; + + stateDb.addDescription(testStateId, restOfTheDescription); + let states = stateDb.getStates(); + expect(states[testStateId].descriptions[0]).toEqual(restOfTheDescription); + + stateDb.addDescription(testStateId, oneLeadingColon); + states = stateDb.getStates(); + expect(states[testStateId].descriptions[1]).toEqual(restOfTheDescription); + + stateDb.addDescription(testStateId, twoLeadingColons); + states = stateDb.getStates(); + expect(states[testStateId].descriptions[2]).toEqual(`:${restOfTheDescription}`); + }); + + it('adds each description to the array of descriptions', () => { + stateDb.addDescription(testStateId, 'description 0'); + stateDb.addDescription(testStateId, 'description 1'); + stateDb.addDescription(testStateId, 'description 2'); + + let states = stateDb.getStates(); + expect(states[testStateId].descriptions.length).toEqual(3); + expect(states[testStateId].descriptions[0]).toEqual('description 0'); + expect(states[testStateId].descriptions[1]).toEqual('description 1'); + expect(states[testStateId].descriptions[2]).toEqual('description 2'); + }); + + it('sanitizes on the description', () => { + stateDb.addDescription( + testStateId, + 'desc outside the script ' + ); + let states = stateDb.getStates(); + expect(states[testStateId].descriptions[0]).toEqual('desc outside the script '); + }); + + it('adds the description to the state with the given id', () => { + stateDb.addDescription(testStateId, 'the description'); + let states = stateDb.getStates(); + expect(states[testStateId].descriptions[0]).toEqual('the description'); + }); + }); });