diff --git a/.cspell/contributors.txt b/.cspell/contributors.txt index bd3ad9da24..f90c064998 100644 --- a/.cspell/contributors.txt +++ b/.cspell/contributors.txt @@ -2,6 +2,7 @@ Ashish Jain cpettitt Dong Cai +jgreywolf Nikolay Rozhkov Peng Xiao subhash-halder diff --git a/cypress/integration/rendering/classDiagram-v2.spec.js b/cypress/integration/rendering/classDiagram-v2.spec.js index 20a1aea0ab..8da82f98c6 100644 --- a/cypress/integration/rendering/classDiagram-v2.spec.js +++ b/cypress/integration/rendering/classDiagram-v2.spec.js @@ -581,4 +581,122 @@ class C13["With Città foreign language"] { logLevel: 1, flowchart: { htmlLabels: false } } ); }); + + describe('when adding generic types', () => { + it('should add properties when type is mentioned in classID', () => { + imgSnapshotTest( + ` + classDiagram-v2 + class Class01~T~ + Class01-T : size() + Class01-T : int chimp + Class01-T : int gorilla + ` + ); + }); + + it('should fallback to matching class name when type is not mentioned in property', () => { + imgSnapshotTest( + ` + classDiagram-v2 + class Class01~T~ + Class01-T : size() + Class01-T : int chimp + Class01 : int gorilla + ` + ); + }); + + it('should fallback to the first matching class name when type is not mentioned in property', () => { + imgSnapshotTest( + ` + classDiagram-v2 + class Class01~T~ + class Class01~X~ + Class01-T : int inClassT + Class01-X : int inClassX + Class01 : int alsoInClassT + ` + ); + }); + + it('should detect generic classes correctly when using different classIDs', () => { + imgSnapshotTest( + ` + classDiagram-v2 + class Class01~T~ + Class01-T : size() + Class01-T : int chimp + Class01 : int gorillaInClassT + class Class01~X~ + Class01-X : size() + Class01-X : int chimp + Class01-X : int gorilla + ` + ); + }); + + it('should render with Generic class and relations', () => { + imgSnapshotTest( + ` + classDiagram-v2 + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01-T : size() + Class01-T : int chimp + Class01-T : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + ` + ); + }); + + it('should render with clickable link when type is not mentioned', () => { + imgSnapshotTest( + ` + classDiagram-v2 + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + link Class01 "google.com" "A Tooltip" + ` + ); + }); + + it('should render with clickable callback when type is not mentioned', () => { + imgSnapshotTest( + ` + classDiagram-v2 + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + callback Class01 "functionCall" "A Tooltip" + ` + ); + }); + + it('should render with clickable link when type is mentioned', () => { + imgSnapshotTest( + ` + classDiagram-v2 + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + link Class01-T "google.com" "A Tooltip" + ` + ); + }); + + it('should render with clickable callback when type is mentioned', () => { + imgSnapshotTest( + ` + classDiagram-v2 + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + callback Class01-T "functionCall" "A Tooltip" + ` + ); + }); + }); }); diff --git a/cypress/integration/rendering/classDiagram.spec.js b/cypress/integration/rendering/classDiagram.spec.js index cab3649df4..95f7d9723a 100644 --- a/cypress/integration/rendering/classDiagram.spec.js +++ b/cypress/integration/rendering/classDiagram.spec.js @@ -513,4 +513,123 @@ describe('Class diagram', () => { cy.get('a').should('have.attr', 'target', '_blank').should('have.attr', 'rel', 'noopener'); }); }); + + describe('when adding generic types', () => { + it('should add properties when type is mentioned in classID', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01-T : size() + Class01-T : int chimp + Class01-T : int gorilla + ` + ); + }); + + it('should fallback to matching class name when type is not mentioned in property', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01-T : size() + Class01-T : int chimp + Class01 : int gorilla + ` + ); + }); + + it('should fallback to the first matching class name when type is not mentioned in property', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + class Class01~X~ + Class01-T : int inClassT + Class01-X : int inClassX + Class01 : int alsoInClassT + ` + ); + }); + + it('should detect generic classes correctly when using different classIDs', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01-T : size() + Class01-T : int chimp + Class01 : int gorillaInClassT + class Class01~X~ + Class01-X : size() + Class01-X : int chimp + Class01-X : int gorilla + ` + ); + }); + + it('should render with Generic class and relations', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01-T : size() + Class01-T : int chimp + Class01-T : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + ` + ); + }); + + // TODO: @jgreywolf These tests should ideally be unit tests, as links cannot be verified visually. + it('should render with clickable link when type is not mentioned', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + link Class01 "google.com" "A Tooltip" + ` + ); + }); + + it('should render with clickable callback when type is not mentioned', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + callback Class01 "functionCall" "A Tooltip" + ` + ); + }); + + it('should render with clickable link when type is mentioned', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + link Class01-T "google.com" "A Tooltip" + ` + ); + }); + + it('should render with clickable callback when type is mentioned', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class01-T : size() + callback Class01-T "functionCall" "A Tooltip" + ` + ); + }); + }); }); diff --git a/docs/syntax/classDiagram.md b/docs/syntax/classDiagram.md index ed15922f13..2f07a868c4 100644 --- a/docs/syntax/classDiagram.md +++ b/docs/syntax/classDiagram.md @@ -240,9 +240,7 @@ class BankAccount{ #### Generic Types -Generics can be represented as part of a class definition, and for class members/return types. In order to denote an item as generic, you enclose that type within `~` (**tilde**). **Nested** type declarations such as `List>` are supported, though generics that include a comma are currently not supported. (such as `List>`) - -> _note_ when a generic is used within a class definition, the generic type is NOT considered part of the class name. i.e.: for any syntax which required you to reference the class name, you need to drop the type part of the definition. This also means that mermaid does not currently support having two classes with the same name, but different generic types. +Generics can be represented as part of a class definition, and for class members/return types. In order to denote an item as generic, you enclose that type within `~` (**tilde**). **Nested** type declarations such as `List>` are supported, though generics that include a comma are currently not supported. (such as `List>`, however there is a workaround that you can use, which is to substitute the `,` with the HTML Entity code #44; (e.g.: `Dictionary ~decimal#44; Queue~Order~~ BuyQueue`) ```mermaid-example classDiagram @@ -274,6 +272,42 @@ Square : +getMessages() List~string~ Square : +getDistanceMatrix() List~List~int~~ ``` +> **Note** > `(v+)` classes defined with a generic type (e.g.: ThisClass\~T\~) will have the type information added to the classname to create a unique classID. This means that you can have multiple classes defined with the same name, but different types. For any syntax where you are required to add the **class name** you should now use the same syntax as adding a class to reference this object (e.g.: ThisClass\~T\~), or just add a `-` in between the classname and type so that the parser associates items correctly. + +```mermaid-example +classDiagram +class Thing~T~{ + int id +} +class Thing~L~{ + List~int~ position + setPoints(List~int~ points) + getPoints() List~int~ +} + +Thing~T~: int id +Thing~L~: int lId + +Thing-T --> Thing-L +``` + +```mermaid +classDiagram +class Thing~T~{ + int id +} +class Thing~L~{ + List~int~ position + setPoints(List~int~ points) + getPoints() List~int~ +} + +Thing~T~: int id +Thing~L~: int lId + +Thing-T --> Thing-L +``` + #### Visibility To describe the visibility (or encapsulation) of an attribute or method/function that is a part of a class (i.e. a class member), optional notation may be placed before that members' name: diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 0e18d1e0fa..e232f0a0fe 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -39,15 +39,17 @@ const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); const splitClassNameAndType = function (_id: string) { const id = common.sanitizeText(_id, getConfig()); let genericType = ''; + let classId = id; let className = id; if (id.indexOf('~') > 0) { const split = id.split('~'); className = sanitizeText(split[0]); genericType = sanitizeText(split[1]); + classId = `${className}-${genericType}`; } - return { className: className, type: genericType }; + return { classId, className, type: genericType }; }; export const setClassLabel = function (_id: string, label: string) { @@ -56,28 +58,29 @@ export const setClassLabel = function (_id: string, label: string) { label = sanitizeText(label); } - const { className } = splitClassNameAndType(id); - classes[className].label = label; + const { classId } = splitClassNameAndType(id); + classes[classId].label = label; }; /** * Function called by parser when a node definition has been found. * - * @param id - Id of the class to add + * @param id - Id of the class to add, which can include generic type info * @public */ -export const addClass = function (_id: string) { +export const getOrAddClass = function (_id: string) { const id = common.sanitizeText(_id, getConfig()); - const { className, type } = splitClassNameAndType(id); + const { classId, className, type } = splitClassNameAndType(id); + // Only add class if not exists - if (Object.hasOwn(classes, className)) { - return; + if (Object.hasOwn(classes, classId)) { + return classes[classId]; } - // alert('Adding class: ' + className); + const name = common.sanitizeText(className, getConfig()); - // alert('Adding class after: ' + name); - classes[name] = { - id: name, + const newClass = { + id: classId, + name: name, type: type, label: name, cssClasses: [], @@ -88,7 +91,10 @@ export const addClass = function (_id: string) { domId: MERMAID_DOM_ID_PREFIX + name + '-' + classCounter, } as ClassNode; + classes[classId] = newClass; classCounter++; + + return newClass; }; /** @@ -134,14 +140,13 @@ export const getNotes = function () { export const addRelation = function (relation: ClassRelation) { log.debug('Adding relation: ' + JSON.stringify(relation)); - addClass(relation.id1); - addClass(relation.id2); + const relation1 = getOrAddClass(relation.id1); + const relation2 = getOrAddClass(relation.id2); - relation.id1 = splitClassNameAndType(relation.id1).className; - relation.id2 = splitClassNameAndType(relation.id2).className; + relation.id1 = relation1.id; + relation.id2 = relation2.id; relation.relationTitle1 = common.sanitizeText(relation.relationTitle1.trim(), getConfig()); - relation.relationTitle2 = common.sanitizeText(relation.relationTitle2.trim(), getConfig()); relations.push(relation); @@ -156,8 +161,8 @@ export const addRelation = function (relation: ClassRelation) { * @public */ export const addAnnotation = function (className: string, annotation: string) { - const validatedClassName = splitClassNameAndType(className).className; - classes[validatedClassName].annotations.push(annotation); + const { classId } = splitClassNameAndType(className); + classes[classId].annotations.push(annotation); }; /** @@ -170,13 +175,9 @@ export const addAnnotation = function (className: string, annotation: string) { * @public */ export const addMember = function (className: string, member: string) { - addClass(className); - - const validatedClassName = splitClassNameAndType(className).className; - const theClass = classes[validatedClassName]; + const theClass = getOrAddClass(className); if (typeof member === 'string') { - // Member can contain white spaces, we trim them out const memberString = member.trim(); if (memberString.startsWith('<<') && memberString.endsWith('>>')) { @@ -218,16 +219,16 @@ export const cleanupLabel = function (label: string) { * Called by parser when assigning cssClass to a class * * @param ids - Comma separated list of ids - * @param className - Class to add + * @param cssClassName - Class to add */ -export const setCssClass = function (ids: string, className: string) { +export const setCssClass = function (ids: string, cssClassName: string) { ids.split(',').forEach(function (_id) { let id = _id; if (_id[0].match(/\d/)) { id = MERMAID_DOM_ID_PREFIX + id; } if (classes[id] !== undefined) { - classes[id].cssClasses.push(className); + classes[id].cssClasses.push(cssClassName); } }); }; @@ -417,18 +418,19 @@ const setDirection = (dir: string) => { * @public */ export const addNamespace = function (id: string) { - if (namespaces[id] !== undefined) { - return; + // Only add namespace if it does not exist + if (!Object.hasOwn(namespaces, id)) { + namespaces[id] = { + id: id, + classes: {}, + children: {}, + domId: MERMAID_DOM_ID_PREFIX + id + '-' + namespaceCounter, + } as NamespaceNode; + + namespaceCounter++; } - namespaces[id] = { - id: id, - classes: {}, - children: {}, - domId: MERMAID_DOM_ID_PREFIX + id + '-' + namespaceCounter, - } as NamespaceNode; - - namespaceCounter++; + return namespaces[id]; }; const getNamespace = function (name: string): NamespaceNode { @@ -447,13 +449,16 @@ const getNamespaces = function (): NamespaceMap { * @public */ export const addClassesToNamespace = function (id: string, classNames: string[]) { - if (namespaces[id] === undefined) { + const namespace = addNamespace(id); + + if (namespace === undefined) { return; } + for (const name of classNames) { - const { className } = splitClassNameAndType(name); - classes[className].parent = id; - namespaces[id].classes[className] = classes[className]; + const theClass = getOrAddClass(name); + theClass.parent = id; + namespace.classes[theClass.id] = theClass; } }; @@ -477,7 +482,7 @@ export default { getAccDescription, setAccDescription, getConfig: () => getConfig().class, - addClass, + addClass: getOrAddClass, bindFunctions, clear, getClass, diff --git a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts index e3dbb17f14..b3a9824def 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -2,6 +2,7 @@ import { parser } from './parser/classDiagram.jison'; import classDb from './classDb.js'; import { vi, describe, it, expect } from 'vitest'; +import type { ClassNode } from './classTypes.js'; const spyOn = vi.spyOn; const staticCssStyle = 'text-decoration:underline;'; @@ -409,6 +410,7 @@ class C13["With Città foreign language"] }, ], "methods": [], + "name": "Student", "styles": [], "type": "", } @@ -859,6 +861,24 @@ describe('given a class diagram with members and methods ', function () { parser.parse(str); }); + + it('should handle multiple classes with same name, and different types types', function () { + const str = `classDiagram + class Car~T~ + Car-T : int numSeats' + class Car~L~`; + + parser.parse(str); + + const classes = parser.yy.getClasses(); + expect(Object.keys(classes).length).toBe(2); + expect(classes['Car-T'].id).toBe('Car-T'); + expect(classes['Car-T'].name).toBe('Car'); + expect(classes['Car-T'].type).toBe('T'); + expect(classes['Car-L'].id).toBe('Car-L'); + expect(classes['Car-L'].name).toBe('Car'); + expect(classes['Car-L'].type).toBe('L'); + }); }); describe('when parsing method definition', function () { @@ -1046,16 +1066,18 @@ foo() }); it('should handle namespace with generic types', () => { - parser.parse(`classDiagram + const str = `classDiagram + namespace space { + class Square~Shape~{ + int : id + List~int~ position + setPoints(List~int~ points) + getPoints() List~int~ + } + } + `; -namespace space { - class Square~Shape~{ - int id - List~int~ position - setPoints(List~int~ points) - getPoints() List~int~ - } -}`); + parser.parse(str); }); }); }); @@ -1097,9 +1119,11 @@ describe('given a class diagram with relationships, ', function () { parser.parse(str); const relations = parser.yy.getRelations(); + const classNode1 = parser.yy.getClass('Class1-T') as ClassNode; - expect(parser.yy.getClass('Class1').id).toBe('Class1'); - expect(parser.yy.getClass('Class1').type).toBe('T'); + expect(classNode1.id).toBe('Class1-T'); + expect(classNode1.type).toBe('T'); + expect(classNode1.name).toBe('Class1'); expect(parser.yy.getClass('Class02').id).toBe('Class02'); expect(relations[0].relation.type1).toBe(classDb.relationType.EXTENSION); expect(relations[0].relation.type2).toBe('none'); @@ -1247,14 +1271,16 @@ describe('given a class diagram with relationships, ', function () { }); it('should handle generic class with relation definitions', function () { - const str = 'classDiagram\n' + 'Class01~T~ <|-- Class02'; + const str = 'classDiagram\n' + 'Class1~T~ <|-- Class02'; parser.parse(str); const relations = parser.yy.getRelations(); + const classNode1 = parser.yy.getClass('Class1-T') as ClassNode; - expect(parser.yy.getClass('Class01').id).toBe('Class01'); - expect(parser.yy.getClass('Class01').type).toBe('T'); + expect(classNode1.id).toBe('Class1-T'); + expect(classNode1.type).toBe('T'); + expect(classNode1.name).toBe('Class1'); expect(parser.yy.getClass('Class02').id).toBe('Class02'); expect(relations[0].relation.type1).toBe(classDb.relationType.EXTENSION); expect(relations[0].relation.type2).toBe('none'); diff --git a/packages/mermaid/src/diagrams/class/classTypes.ts b/packages/mermaid/src/diagrams/class/classTypes.ts index 85be3a4e87..a16df1c167 100644 --- a/packages/mermaid/src/diagrams/class/classTypes.ts +++ b/packages/mermaid/src/diagrams/class/classTypes.ts @@ -3,6 +3,7 @@ import { parseGenericTypes, sanitizeText } from '../common/common.js'; export interface ClassNode { id: string; + name: string; type: string; label: string; cssClasses: string[]; diff --git a/packages/mermaid/src/docs/syntax/classDiagram.md b/packages/mermaid/src/docs/syntax/classDiagram.md index 029d11b540..c3fd40a412 100644 --- a/packages/mermaid/src/docs/syntax/classDiagram.md +++ b/packages/mermaid/src/docs/syntax/classDiagram.md @@ -143,9 +143,7 @@ class BankAccount{ #### Generic Types -Generics can be represented as part of a class definition, and for class members/return types. In order to denote an item as generic, you enclose that type within `~` (**tilde**). **Nested** type declarations such as `List>` are supported, though generics that include a comma are currently not supported. (such as `List>`) - -> _note_ when a generic is used within a class definition, the generic type is NOT considered part of the class name. i.e.: for any syntax which required you to reference the class name, you need to drop the type part of the definition. This also means that mermaid does not currently support having two classes with the same name, but different generic types. +Generics can be represented as part of a class definition, and for class members/return types. In order to denote an item as generic, you enclose that type within `~` (**tilde**). **Nested** type declarations such as `List>` are supported, though generics that include a comma are currently not supported. (such as `List>`, however there is a workaround that you can use, which is to substitute the `,` with the HTML Entity code #44; (e.g.: `Dictionary ~decimal#44; Queue~Order~~ BuyQueue`) ```mermaid-example classDiagram @@ -162,6 +160,27 @@ Square : +getMessages() List~string~ Square : +getDistanceMatrix() List~List~int~~ ``` +```note +`(v+)` classes defined with a generic type (e.g.: ThisClass~T~) will have the type information added to the classname to create a unique classID. This means that you can have multiple classes defined with the same name, but different types. For any syntax where you are required to add the **class name** you should now use the same syntax as adding a class to reference this object (e.g.: ThisClass~T~), or just add a `-` in between the classname and type so that the parser associates items correctly. +``` + +```mermaid-example +classDiagram +class Thing~T~{ + int id +} +class Thing~L~{ + List~int~ position + setPoints(List~int~ points) + getPoints() List~int~ +} + +Thing~T~: int id +Thing~L~: int lId + +Thing-T --> Thing-L +``` + #### Visibility To describe the visibility (or encapsulation) of an attribute or method/function that is a part of a class (i.e. a class member), optional notation may be placed before that members' name: