Adding ticket handling

This commit is contained in:
Knut Sveidqvist 2024-10-10 08:23:58 -07:00
parent 93f2c241b8
commit 290c678dc7
16 changed files with 415 additions and 128 deletions

View File

@ -19,6 +19,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
'xyChart', 'xyChart',
'requirement', 'requirement',
'mindmap', 'mindmap',
'kanban',
'timeline', 'timeline',
'gitGraph', 'gitGraph',
'c4', 'c4',

View File

@ -12,6 +12,7 @@ gantt
gitgraph gitgraph
gzipped gzipped
handDrawn handDrawn
kanban
knsv knsv
Knut Knut
marginx marginx

View File

@ -84,19 +84,27 @@
<body> <body>
<pre id="diagram4" class="mermaid"> <pre id="diagram4" class="mermaid">
---
config:
kanban:
ticketBaseUrl: 'https://mermaidchart.atlassian.net/browse/#TICKET#'
---
kanban kanban
id1[Todo] id1[Todo]
id2[Create JISON] docs[Create Documentation]
id3[Update DB function] docs[Create Blog about the new diagram]
id4[Create parsing tests]
id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes.]
id66[last item]
id7[In progress] id7[In progress]
id8[Design grammar]
id9[Ready for deploy] id9[Ready for deploy]
id10[Ready for test] id10[Ready for test]
id5[define getData]
id11[Done] id11[Done]
id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes. And some more just for the extra flare.]
id8[Design grammar]@{ assigned: 'knsv' }
id5[define getData]
id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: MC-2036, priority: 'Very High'}
id3[Update DB function]@{ ticket: MC-2037, assigned: knsv, priority: 'High' }
id4[Create parsing tests]@{ ticket: MC-2038, assigned: 'K.Sveidqvist', priority: 'High' }
id66[last item]@{ priority: 'Very Low', assigned: 'knsv' }
id12[Can't reproduce] id12[Can't reproduce]
</pre> </pre>
<script type="module"> <script type="module">

View File

@ -193,6 +193,7 @@ export interface MermaidConfig {
requirement?: RequirementDiagramConfig; requirement?: RequirementDiagramConfig;
architecture?: ArchitectureDiagramConfig; architecture?: ArchitectureDiagramConfig;
mindmap?: MindmapDiagramConfig; mindmap?: MindmapDiagramConfig;
kanban?: KanbanDiagramConfig;
gitGraph?: GitGraphDiagramConfig; gitGraph?: GitGraphDiagramConfig;
c4?: C4DiagramConfig; c4?: C4DiagramConfig;
sankey?: SankeyDiagramConfig; sankey?: SankeyDiagramConfig;
@ -1023,6 +1024,17 @@ export interface MindmapDiagramConfig extends BaseDiagramConfig {
padding?: number; padding?: number;
maxNodeWidth?: number; maxNodeWidth?: number;
} }
/**
* The object containing configurations specific for kanban diagrams
*
* This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "KanbanDiagramConfig".
*/
export interface KanbanDiagramConfig extends BaseDiagramConfig {
padding?: number;
sectionWidth?: number;
ticketBaseUrl?: string;
}
/** /**
* This interface was referenced by `MermaidConfig`'s JSON-Schema * This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "GitGraphDiagramConfig". * via the `definition` "GitGraphDiagramConfig".

View File

@ -58,6 +58,12 @@ const config: RequiredDeep<MermaidConfig> = {
tickInterval: undefined, tickInterval: undefined,
useWidth: undefined, // can probably be removed since `configKeys` already includes this useWidth: undefined, // can probably be removed since `configKeys` already includes this
}, },
kanban: {
ticketBaseUrl: '', // can probably be removed since `configKeys` already includes this
padding: 8,
sectionWidth: 200,
...defaultConfigJson.kanban,
},
c4: { c4: {
...defaultConfigJson.c4, ...defaultConfigJson.c4,
useWidth: undefined, useWidth: undefined,

View File

@ -380,3 +380,89 @@ root
expect(child2.nodeId).toEqual('B'); expect(child2.nodeId).toEqual('B');
}); });
}); });
describe('item data data', function () {
beforeEach(function () {
kanban.yy = kanbanDB;
kanban.yy.clear();
setLogLevel('trace');
});
it('KNBN-30 should be possible to set the priority', function () {
let str = `kanban
root
`;
str = `kanban
root@{ priority: high }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].priority).toEqual('high');
});
it('KNBN-31 should be possible to set the assignment', function () {
const str = `kanban
root@{ assigned: knsv }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].assigned).toEqual('knsv');
});
it('KNBN-32 should be possible to set the icon', function () {
const str = `kanban
root@{ icon: star }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].icon).toEqual('star');
});
it('KNBN-33 should be possible to set the icon', function () {
const str = `kanban
root@{ icon: star }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].icon).toEqual('star');
});
it('KNBN-34 should be possible to set the metadata using multiple lines', function () {
const str = `kanban
root@{
icon: star
assigned: knsv
}
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].icon).toEqual('star');
expect(sections[0].assigned).toEqual('knsv');
});
it('KNBN-35 should be possible to set the metadata using one line', function () {
const str = `kanban
root@{ icon: star, assigned: knsv }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].icon).toEqual('star');
expect(sections[0].assigned).toEqual('knsv');
});
it('KNBN-36 should be possible to set the label using the new syntax', function () {
const str = `kanban
root@{ icon: star, label: 'fix things' }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].descr).toEqual('fix things');
});
it('KNBN-37 should be possible to set the external id', function () {
const str = `kanban
root@{ ticket: MC-1234 }
`;
kanban.parse(str);
const sections = kanban.yy.getSections();
expect(sections[0].nodeId).toEqual('root');
expect(sections[0].ticket).toEqual('MC-1234');
});
});

View File

@ -2,12 +2,14 @@ import { getConfig } from '../../diagram-api/diagramAPI.js';
import type { D3Element } from '../../types.js'; import type { D3Element } from '../../types.js';
import { sanitizeText } from '../../diagrams/common/common.js'; import { sanitizeText } from '../../diagrams/common/common.js';
import { log } from '../../logger.js'; import { log } from '../../logger.js';
import type { KanbanNode } from './kanbanTypes.js'; import type { KanbanInternalNode } from './kanbanTypes.js';
import type { Node, Edge } from '../../rendering-util/types.js'; import type { Node, Edge, KanbanNode } from '../../rendering-util/types.js';
import defaultConfig from '../../defaultConfig.js'; import defaultConfig from '../../defaultConfig.js';
import type { NodeMetaData } from '../../types.js';
import * as yaml from 'js-yaml';
let nodes: KanbanNode[] = []; let nodes: KanbanInternalNode[] = [];
let sections: KanbanNode[] = []; let sections: KanbanInternalNode[] = [];
let cnt = 0; let cnt = 0;
let elements: Record<number, D3Element> = {}; let elements: Record<number, D3Element> = {};
@ -38,9 +40,6 @@ const getSection = function (level: number) {
throw new Error('Items without section detected, found section ("' + nodes[i].descr + '")'); throw new Error('Items without section detected, found section ("' + nodes[i].descr + '")');
} }
} }
// if (!lastSection) {
// // console.log('No last section');
// }
if (level === lastSection?.level) { if (level === lastSection?.level) {
return null; return null;
} }
@ -59,15 +58,15 @@ const getData = function () {
const sections = getSections(); const sections = getSections();
const conf = getConfig(); const conf = getConfig();
// const id: string = sanitizeText(id, conf) || 'identifier' + cnt++;
for (const section of sections) { for (const section of sections) {
const node = { const node = {
id: section.nodeId, id: section.nodeId,
label: sanitizeText(section.descr, conf), label: sanitizeText(section.descr, conf),
isGroup: true, isGroup: true,
ticket: section.ticket,
shape: 'kanbanSection', shape: 'kanbanSection',
} satisfies Node; } satisfies KanbanNode;
nodes.push(node); nodes.push(node);
for (const item of section.children) { for (const item of section.children) {
const childNode = { const childNode = {
@ -75,10 +74,14 @@ const getData = function () {
parentId: section.nodeId, parentId: section.nodeId,
label: sanitizeText(item.descr, conf), label: sanitizeText(item.descr, conf),
isGroup: false, isGroup: false,
ticket: item?.ticket,
priority: item?.priority,
assigned: item?.assigned,
icon: item?.icon,
shape: 'kanbanItem', shape: 'kanbanItem',
rx: 5, rx: 5,
cssStyles: ['text-align: left'], cssStyles: ['text-align: left'],
} satisfies Node; } satisfies KanbanNode;
nodes.push(childNode); nodes.push(childNode);
} }
} }
@ -86,7 +89,7 @@ const getData = function () {
return { nodes, edges, other: {}, config: getConfig() }; return { nodes, edges, other: {}, config: getConfig() };
}; };
const addNode = (level: number, id: string, descr: string, type: number) => { const addNode = (level: number, id: string, descr: string, type: number, shapeData: string) => {
// log.info('addNode level=', level, 'id=', id, 'descr=', descr, 'type=', type); // log.info('addNode level=', level, 'id=', id, 'descr=', descr, 'type=', type);
const conf = getConfig(); const conf = getConfig();
let padding: number = conf.mindmap?.padding ?? defaultConfig.mindmap.padding; let padding: number = conf.mindmap?.padding ?? defaultConfig.mindmap.padding;
@ -106,9 +109,47 @@ const addNode = (level: number, id: string, descr: string, type: number) => {
children: [], children: [],
width: conf.mindmap?.maxNodeWidth ?? defaultConfig.mindmap.maxNodeWidth, width: conf.mindmap?.maxNodeWidth ?? defaultConfig.mindmap.maxNodeWidth,
padding, padding,
} satisfies KanbanNode; } satisfies KanbanInternalNode;
if (shapeData !== undefined) {
let yamlData;
// detect if shapeData contains a newline character
// console.log('shapeData', shapeData);
if (!shapeData.includes('\n')) {
// console.log('yamlData shapeData has no new lines', shapeData);
yamlData = '{\n' + shapeData + '\n}';
} else {
// console.log('yamlData shapeData has new lines', shapeData);
yamlData = shapeData + '\n';
}
const doc = yaml.load(yamlData, { schema: yaml.JSON_SCHEMA }) as NodeMetaData;
// console.log('yamlData', doc);
if (doc.shape && (doc.shape !== doc.shape.toLowerCase() || doc.shape.includes('_'))) {
throw new Error(`No such shape: ${doc.shape}. Shape names should be lowercase.`);
}
if (doc?.shape) {
node.type = doc?.shape;
}
if (doc?.label) {
node.descr = doc?.label;
}
if (doc?.icon) {
node.icon = doc?.icon;
}
if (doc?.assigned) {
node.assigned = doc?.assigned;
}
if (doc?.ticket) {
node.ticket = doc?.ticket;
}
if (doc?.priority) {
node.priority = doc?.priority;
}
}
const section = getSection(level); const section = getSection(level);
console.log('Node ', node.descr, ' section', section?.descr);
if (section) { if (section) {
section.children.push(node); section.children.push(node);
// Keep all nodes in the list // Keep all nodes in the list

View File

@ -1,86 +1,13 @@
import type cytoscape from 'cytoscape';
// @ts-expect-error No types available // @ts-expect-error No types available
import coseBilkent from 'cytoscape-cose-bilkent';
import { select } from 'd3';
import type { MermaidConfig } from '../../config.type.js';
import { getConfig } from '../../diagram-api/diagramAPI.js'; import { getConfig } from '../../diagram-api/diagramAPI.js';
import type { DrawDefinition } from '../../diagram-api/types.js'; import type { DrawDefinition } from '../../diagram-api/types.js';
import { log } from '../../logger.js'; import { log } from '../../logger.js';
import type { D3Element } from '../../types.js';
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
import { setupGraphViewbox } from '../../setupGraphViewbox.js'; import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import type { KanbanDB, KanbanNode } from './kanbanTypes.js'; import type { KanbanDB } from './kanbanTypes.js';
import defaultConfig from '../../defaultConfig.js'; import defaultConfig from '../../defaultConfig.js';
import { insertCluster, positionCluster } from '../../rendering-util/rendering-elements/clusters'; import { insertCluster } from '../../rendering-util/rendering-elements/clusters.js';
import { insertNode, positionNode } from '../../rendering-util/rendering-elements/nodes'; import { insertNode, positionNode } from '../../rendering-util/rendering-elements/nodes.js';
declare module 'cytoscape' {
interface EdgeSingular {
_private: {
bodyBounds: unknown;
rscratch: {
startX: number;
startY: number;
midX: number;
midY: number;
endX: number;
endY: number;
};
};
}
}
function drawEdges(edgesEl: D3Element, cy: cytoscape.Core) {
cy.edges().map((edge, id) => {
const data = edge.data();
if (edge[0]._private.bodyBounds) {
const bounds = edge[0]._private.rscratch;
log.trace('Edge: ', id, data);
edgesEl
.insert('path')
.attr(
'd',
`M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} `
)
.attr('class', 'edge section-edge-' + data.section + ' edge-depth-' + data.depth);
}
});
}
function addNodes(mindmap: KanbanNode, cy: cytoscape.Core, conf: MermaidConfig, level: number) {
cy.add({
group: 'nodes',
data: {
id: mindmap.id.toString(),
labelText: mindmap.descr,
height: mindmap.height,
width: mindmap.width,
level: level,
nodeId: mindmap.id,
padding: mindmap.padding,
type: mindmap.type,
},
position: {
x: mindmap.x!,
y: mindmap.y!,
},
});
if (mindmap.children) {
mindmap.children.forEach((child) => {
addNodes(child, cy, conf, level + 1);
cy.add({
group: 'edges',
data: {
id: `${mindmap.id}_${child.id}`,
source: mindmap.id,
target: child.id,
depth: level,
section: child.section,
},
});
});
}
}
export const draw: DrawDefinition = async (text, id, _version, diagObj) => { export const draw: DrawDefinition = async (text, id, _version, diagObj) => {
log.debug('Rendering mindmap diagram\n' + text); log.debug('Rendering mindmap diagram\n' + text);
@ -106,8 +33,9 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj) => {
const padding = 10; const padding = 10;
for (const section of sections) { for (const section of sections) {
const WIDTH = 200; const WIDTH = conf?.kanban?.sectionWidth || 200;
let y = (-WIDTH * 3) / 2 + 40; const top = (-WIDTH * 3) / 2 + 25;
let y = top;
cnt = cnt + 1; cnt = cnt + 1;
section.x = WIDTH * cnt + ((cnt - 1) * padding) / 2; section.x = WIDTH * cnt + ((cnt - 1) * padding) / 2;
section.width = WIDTH; section.width = WIDTH;
@ -118,21 +46,21 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj) => {
// Todo, use theme variable THEME_COLOR_LIMIT instead of 10 // Todo, use theme variable THEME_COLOR_LIMIT instead of 10
section.cssClasses = section.cssClasses + ' section-' + cnt; section.cssClasses = section.cssClasses + ' section-' + cnt;
const cluster = await insertCluster(sectionsElem, section); const sectionObj = await insertCluster(sectionsElem, section);
const sectionItems = data4Layout.nodes.filter((node) => node.parentId === section.id); const sectionItems = data4Layout.nodes.filter((node) => node.parentId === section.id);
// positionCluster(section);
for (const item of sectionItems) { for (const item of sectionItems) {
item.x = section.x; item.x = section.x;
item.width = WIDTH - 2 * padding; item.width = WIDTH - 1.5 * padding;
// item.height = 100; const nodeEl = await insertNode(nodesElem, item, { config: conf });
const nodeEl = await insertNode(nodesElem, item);
console.log('ITEM', item, 'bbox=', nodeEl.node().getBBox());
const bbox = nodeEl.node().getBBox(); const bbox = nodeEl.node().getBBox();
item.y = y + bbox.height / 2; item.y = y + bbox.height / 2;
// item.height = 150;
await positionNode(item); await positionNode(item);
y = item.y + bbox.height / 2 + padding; y = item.y + bbox.height / 2 + padding / 2;
} }
const rect = sectionObj.cluster.select('rect');
const height = Math.max(y - top + 3 * padding, 50);
rect.attr('height', height);
} }
// Setup the view box and size of the svg element // Setup the view box and size of the svg element

View File

@ -1,22 +1,24 @@
import type { RequiredDeep } from 'type-fest'; import type { RequiredDeep } from 'type-fest';
import type kanbanDb from './kanbanDb.js'; import type kanbanDb from './kanbanDb.js';
export interface KanbanNode { export interface KanbanInternalNode {
id: number; id: number;
nodeId: string; nodeId: string;
level: number; level: number;
descr: string; descr: string;
type: number; type: number;
children: KanbanNode[]; children: KanbanInternalNode[];
width: number; width: number;
padding: number; padding: number;
section?: number; section?: number;
height?: number; height?: number;
class?: string; class?: string;
icon?: string; icon?: string;
ticket?: string;
priority?: string;
x?: number; x?: number;
y?: number; y?: number;
} }
export type FilledKanbanNode = RequiredDeep<KanbanNode>; export type FilledKanbanNode = RequiredDeep<KanbanInternalNode>;
export type KanbanDB = typeof kanbanDb; export type KanbanDB = typeof kanbanDb;

View File

@ -15,12 +15,39 @@
%x NSTR2 %x NSTR2
%x ICON %x ICON
%x CLASS %x CLASS
%x shapeData
%x shapeDataStr
%x shapeDataEndBracket
%% %%
\@\{ {
// console.log('=> shapeData', yytext);
this.pushState("shapeData"); yytext=""; return 'SHAPE_DATA' }
<shapeData>["] {
// console.log('=> shapeDataStr', yytext);
this.pushState("shapeDataStr");
return 'SHAPE_DATA';
}
<shapeDataStr>["] {
// console.log('shapeData <==', yytext);
this.popState(); return 'SHAPE_DATA'}
<shapeDataStr>[^\"]+ {
// console.log('shapeData', yytext);
const re = /\n\s*/g;
yytext = yytext.replace(re,"<br/>");
return 'SHAPE_DATA'}
<shapeData>[^}^"]+ {
// console.log('shapeData', yytext);
return 'SHAPE_DATA';
}
<shapeData>"}" {
// console.log('<== root', yytext)
this.popState();
}
\s*\%\%.* {yy.getLogger().trace('Found comment',yytext); return 'SPACELINE';} \s*\%\%.* {yy.getLogger().trace('Found comment',yytext); return 'SPACELINE';}
// \%\%[^\n]*\n /* skip comments */ // \%\%[^\n]*\n /* skip comments */
"kanban" return 'KANBAN'; "kanban" {return 'KANBAN';}
":::" { this.begin('CLASS'); } ":::" { this.begin('CLASS'); }
<CLASS>.+ { this.popState();return 'CLASS'; } <CLASS>.+ { this.popState();return 'CLASS'; }
<CLASS>\n { this.popState();} <CLASS>\n { this.popState();}
@ -40,7 +67,7 @@
"[" { this.begin('NODE');return 'NODE_DSTART'; } "[" { this.begin('NODE');return 'NODE_DSTART'; }
[\s]+ return 'SPACELIST' /* skip all whitespace */ ; [\s]+ return 'SPACELIST' /* skip all whitespace */ ;
// !(-\() return 'NODE_ID'; // !(-\() return 'NODE_ID';
[^\(\[\n\)\{\}]+ return 'NODE_ID'; [^\(\[\n\)\{\}@]+ {return 'NODE_ID';}
<<EOF>> return 'EOF'; <<EOF>> return 'EOF';
<NODE>["][`] { this.begin("NSTR2");} <NODE>["][`] { this.begin("NSTR2");}
<NSTR2>[^`"]+ { return "NODE_DESCR";} <NSTR2>[^`"]+ { return "NODE_DESCR";}
@ -97,10 +124,12 @@ document
; ;
statement statement
: SPACELIST node { yy.getLogger().info('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type); } : SPACELIST node shapeData { yy.getLogger().info('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type, $3); }
| SPACELIST node { yy.getLogger().info('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type); }
| SPACELIST ICON { yy.getLogger().trace('Icon: ',$2);yy.decorateNode({icon: $2}); } | SPACELIST ICON { yy.getLogger().trace('Icon: ',$2);yy.decorateNode({icon: $2}); }
| SPACELIST CLASS { yy.decorateNode({class: $2}); } | SPACELIST CLASS { yy.decorateNode({class: $2}); }
| SPACELINE { yy.getLogger().trace('SPACELIST');} | SPACELINE { yy.getLogger().trace('SPACELIST');}
| node shapeData { yy.getLogger().trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type, $2); }
| node { yy.getLogger().trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type); } | node { yy.getLogger().trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type); }
| ICON { yy.decorateNode({icon: $1}); } | ICON { yy.decorateNode({icon: $1}); }
| CLASS { yy.decorateNode({class: $1}); } | CLASS { yy.decorateNode({class: $1}); }
@ -120,8 +149,18 @@ nodeWithoutId
; ;
nodeWithId nodeWithId
: NODE_ID { $$ = { id: $1, descr: $1, type: yy.nodeType.DEFAULT }; } : NODE_ID { $$ = { id: $1, descr: $1, type: 0 }; }
| NODE_ID NODE_DSTART NODE_DESCR NODE_DEND | NODE_ID NODE_DSTART NODE_DESCR NODE_DEND
{ yy.getLogger().trace("node found ..", $1); $$ = { id: $1, descr: $3, type: yy.getType($2, $4) }; } { yy.getLogger().trace("node found ..", $1); $$ = { id: $1, descr: $3, type: yy.getType($2, $4) }; }
; ;
shapeData:
shapeData SHAPE_DATA
{ $$ = $1 + $2; }
| SHAPE_DATA
{ $$ = $1; }
;
%% %%

View File

@ -14,13 +14,16 @@ const genSections: DiagramStylesProvider = (options) => {
} }
} }
const adjuster = (color: string, level: number) =>
options.darkMode ? darken(color, level) : lighten(color, level);
for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) {
const sw = '' + (17 - 3 * i); const sw = '' + (17 - 3 * i);
sections += ` sections += `
.section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${ .section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${
i - 1 i - 1
} polygon, .section-${i - 1} path { } polygon, .section-${i - 1} path {
fill: ${options['cScale' + i]}; fill: ${adjuster(options['cScale' + i], 10)};
} }
.section-${i - 1} text { .section-${i - 1} text {
fill: ${options['cScaleLabel' + i]}; fill: ${options['cScaleLabel' + i]};
@ -56,6 +59,12 @@ const genSections: DiagramStylesProvider = (options) => {
stroke: ${options.nodeBorder}; stroke: ${options.nodeBorder};
stroke-width: 1px; stroke-width: 1px;
} }
.kanban-ticket-link {
fill: ${options.background};
stroke: ${options.nodeBorder};
text-decoration: underline;
}
`; `;
} }
return sections; return sections;

View File

@ -1,29 +1,97 @@
import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; import { labelHelper, insertLabel, updateNodeBounds, getNodeClasses } from './util.js';
import intersect from '../intersect/index.js'; import intersect from '../intersect/index.js';
import type { Node } from '../../types.js'; import type { KanbanNode } from '../../types.js';
import { createRoundedRectPathD } from './roundedRectPath.js'; import { createRoundedRectPathD } from './roundedRectPath.js';
import { userNodeOverrides, styles2String } from './handDrawnShapeStyles.js'; import { userNodeOverrides, styles2String } from './handDrawnShapeStyles.js';
import rough from 'roughjs'; import rough from 'roughjs';
const colorFromPriority = (priority: KanbanNode['priority']) => {
export const kanbanItem = async (parent: SVGAElement, node: Node) => { switch (priority) {
case 'Very High':
return 'red';
case 'High':
return 'orange';
case 'Low':
return 'blue';
case 'Very Low':
return 'lightblue';
}
};
export const kanbanItem = async (parent: SVGAElement, node: KanbanNode, { config }) => {
const { labelStyles, nodeStyles } = styles2String(node); const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles; node.labelStyle = labelStyles;
// console.log('IPI labelStyles:', labelStyles); // console.log('IPI labelStyles:', labelStyles);
// const labelPaddingX = 10;
const labelPaddingX = 10; const labelPaddingX = 10;
const orgWidth = node.width; const orgWidth = node.width;
node.width = (node.width ?? 200) - 2 * labelPaddingX; node.width = (node.width ?? 200) - 10;
console.log('APA123 kanbanItem', node.labelStyle); // console.log('APA123 kanbanItem priority', node?.priority);
const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); const {
shapeSvg,
bbox,
label: labelElTitle,
} = await labelHelper(parent, node, getNodeClasses(node));
const padding = node.padding || 10;
// elem.insert('svg:a').attr('xlink:href', node.link).attr('target', target);
// console.log('STO node config.kanban:', config.kanban, config.kanban);
let ticketUrl = '';
let link;
// console.log('STO ticket:', node.ticket);
if (node.ticket && config.kanban.ticketBaseUrl) {
ticketUrl = config.kanban.ticketBaseUrl.replace('#TICKET#', node.ticket);
link = shapeSvg
.insert('svg:a', ':first-child')
.attr('class', 'kanban-ticket-link')
.attr('xlink:href', ticketUrl)
.attr('target', '_blank');
}
const options = {
useHtmlLabels: node.useHtmlLabels,
labelStyle: node.labelStyle,
width: node.width,
icon: node.icon,
img: node.img,
padding: node.padding,
centerLabel: false,
};
const { label: labelEl, bbox: bbox2 } = await insertLabel(
link ? link : shapeSvg,
node.ticket || '',
options
);
const { label: labelElAssigned, bbox: bboxAssigned } = await insertLabel(
shapeSvg,
node.assigned || '',
options
);
node.width = orgWidth; node.width = orgWidth;
const labelPaddingY = 10; const labelPaddingY = 10;
const totalWidth = node?.width || 0; const totalWidth = node?.width || 0;
const totalHeight = Math.max(bbox.height + labelPaddingY * 2, node?.height || 0); const heightAdj = Math.max(bbox2.height, bboxAssigned.height) / 2;
const totalHeight = Math.max(bbox.height + labelPaddingY * 2, node?.height || 0) + heightAdj;
const x = -totalWidth / 2; const x = -totalWidth / 2;
const y = -totalHeight / 2; const y = -totalHeight / 2;
labelElTitle.attr(
'transform',
'translate(' + (padding - totalWidth / 2) + ', ' + (-heightAdj - bbox.height / 2) + ')'
);
labelEl.attr(
'transform',
'translate(' + (padding - totalWidth / 2) + ', ' + (-heightAdj + bbox.height / 2) + ')'
);
labelElAssigned.attr(
'transform',
'translate(' +
(padding + totalWidth / 2 - bboxAssigned.width - 2 * labelPaddingX) +
', ' +
(-heightAdj + bbox.height / 2) +
')'
);
// log.info('IPI node = ', node); // log.info('IPI node = ', node);
let rect; let rect;
const { rx, ry } = node; const { rx, ry } = node;
const { cssStyles } = node; const { cssStyles } = node;
@ -51,9 +119,25 @@ export const kanbanItem = async (parent: SVGAElement, node: Node) => {
.attr('y', y) .attr('y', y)
.attr('width', totalWidth) .attr('width', totalWidth)
.attr('height', totalHeight); .attr('height', totalHeight);
if (node.priority) {
const line = shapeSvg.append('line', ':first-child');
const lineX = x + 2;
const y1 = y + Math.floor((rx ?? 0) / 2);
const y2 = y + totalHeight - Math.floor((rx ?? 0) / 2);
line
.attr('x1', lineX)
.attr('y1', y1)
.attr('x2', lineX)
.attr('y2', y2)
.attr('stroke-width', '4')
.attr('stroke', colorFromPriority(node.priority));
}
} }
updateNodeBounds(node, rect); updateNodeBounds(node, rect);
node.height = totalHeight;
node.intersect = function (point) { node.intersect = function (point) {
return intersect.rect(node, point); return intersect.rect(node, point);

View File

@ -4,7 +4,7 @@ import { select } from 'd3';
import { evaluate, sanitizeText } from '../../../diagrams/common/common.js'; import { evaluate, sanitizeText } from '../../../diagrams/common/common.js';
import { decodeEntities } from '../../../utils.js'; import { decodeEntities } from '../../../utils.js';
export const labelHelper = async (parent, node, _classes) => { export const labelHelper = async (parent, node, _classes, _shapeSvg) => {
let cssClasses; let cssClasses;
const useHtmlLabels = node.useHtmlLabels || evaluate(getConfig().flowchart.htmlLabels); const useHtmlLabels = node.useHtmlLabels || evaluate(getConfig().flowchart.htmlLabels);
if (!_classes) { if (!_classes) {
@ -14,7 +14,9 @@ export const labelHelper = async (parent, node, _classes) => {
} }
// Add outer g element // Add outer g element
const shapeSvg = parent const shapeSvg = _shapeSvg
? _shapeSvg
: parent
.insert('g') .insert('g')
.attr('class', cssClasses) .attr('class', cssClasses)
.attr('id', node.domId || node.id); .attr('id', node.domId || node.id);
@ -106,6 +108,46 @@ export const labelHelper = async (parent, node, _classes) => {
return { shapeSvg, bbox, halfPadding, label: labelEl }; return { shapeSvg, bbox, halfPadding, label: labelEl };
}; };
export const insertLabel = async (parent, label, options) => {
const useHtmlLabels = options.useHtmlLabels || evaluate(getConfig().flowchart.htmlLabels);
// Create the label and insert it after the rect
const labelEl = parent.insert('g').attr('class', 'label').attr('style', options.labelStyle);
let text;
text = await createText(labelEl, sanitizeText(decodeEntities(label), getConfig()), {
useHtmlLabels,
width: options.width || getConfig().flowchart.wrappingWidth,
cssClasses: 'markdown-node-label',
style: options.labelStyle,
addSvgBackground: !!options.icon || !!options.img,
});
// Get the size of the label
let bbox = text.getBBox();
const halfPadding = options.padding / 2;
if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = text.children[0];
const dv = select(text);
bbox = div.getBoundingClientRect();
dv.attr('width', bbox.width);
dv.attr('height', bbox.height);
}
// Center the label
if (useHtmlLabels) {
labelEl.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')');
} else {
labelEl.attr('transform', 'translate(' + 0 + ', ' + -bbox.height / 2 + ')');
}
if (options.centerLabel) {
labelEl.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')');
}
labelEl.insert('rect', ':first-child');
return { shapeSvg: parent, bbox, halfPadding, label: labelEl };
};
export const updateNodeBounds = (node, element) => { export const updateNodeBounds = (node, element) => {
const bbox = element.node().getBBox(); const bbox = element.node().getBBox();
node.width = bbox.width; node.width = bbox.width;

View File

@ -148,3 +148,11 @@ export interface ShapeRenderOptions {
config: MermaidConfig; config: MermaidConfig;
dir: string; dir: string;
} }
export interface KanbanNode extends Node {
// Kanban specif data
priority?: 'Very High' | 'High' | 'Medium' | 'Low' | 'Very Low';
ticket?: string;
assigned?: string;
icon?: string;
}

View File

@ -279,6 +279,8 @@ properties:
$ref: '#/$defs/ArchitectureDiagramConfig' $ref: '#/$defs/ArchitectureDiagramConfig'
mindmap: mindmap:
$ref: '#/$defs/MindmapDiagramConfig' $ref: '#/$defs/MindmapDiagramConfig'
kanban:
$ref: '#/$defs/KanbanDiagramConfig'
gitGraph: gitGraph:
$ref: '#/$defs/GitGraphDiagramConfig' $ref: '#/$defs/GitGraphDiagramConfig'
c4: c4:
@ -964,6 +966,23 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
type: number type: number
default: 200 default: 200
KanbanDiagramConfig:
title: Kanban Diagram Config
allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]
description: The object containing configurations specific for kanban diagrams
type: object
unevaluatedProperties: false
properties:
padding:
type: number
default: 8
sectionWidth:
type: number
default: 200
ticketBaseUrl:
type: string
default: ""
PieDiagramConfig: PieDiagramConfig:
title: Pie Diagram Config title: Pie Diagram Config
allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]

View File

@ -8,6 +8,7 @@ export interface NodeMetaData {
w?: string; w?: string;
h?: string; h?: string;
constraint?: 'on' | 'off'; constraint?: 'on' | 'off';
priority: 'Very High' | 'High' | 'Medium' | 'Low' | 'Very Low';
} }
import type { MermaidConfig } from './config.type.js'; import type { MermaidConfig } from './config.type.js';