Skip to content

Commit

Permalink
Add nested group support
Browse files Browse the repository at this point in the history
Fix #9
  • Loading branch information
goto1134 authored May 30, 2023
1 parent 7361943 commit 7273373
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ open class D2Exporter : AbstractDiagramExporter() {
const val D2_CONNECTION_ANIMATED = "d2.animated"
const val D2_FILL_PATTERN = "d2.fill_pattern"
const val STRUCTURIZR_INCLUDE_SOFTWARE_SYSTEM_BOUNDARIES = "structurizr.softwareSystemBoundaries"
const val STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME = "structurizr.groupSeparator"
}

override fun createDiagram(view: ModelView, definition: String) = D2Diagram(view, definition)
Expand Down Expand Up @@ -139,8 +140,7 @@ open class D2Exporter : AbstractDiagramExporter() {
}
}
if (view is ComponentView) {
val containers = elementsInStep.filterIsInstance<Component>().map { it.container }
.sortedBy { it.id }
val containers = elementsInStep.filterIsInstance<Component>().map { it.container }.sortedBy { it.id }
if (view.includeSoftwareSystemBoundaries) {
containers.map { it.softwareSystem }.sortedBy { it.id }.forEach {
stepsAnimationState.ifNewElement(it) {
Expand Down Expand Up @@ -168,9 +168,11 @@ open class D2Exporter : AbstractDiagramExporter() {
stepsAnimationState.ifNewElement(element) {
writeElement(view, element, writer)
}
stepsAnimationState.ifNewGroup(element.group) {
writeGroup(view, element, writer)
}
element.groupsWithPathsOrNull()
?.filter(stepsAnimationState::addGroup)
?.forEach {
writeGroup(view, it, writer)
}
}

override fun isAnimationSupported(view: ModelView) = view.animationType == AnimationType.FRAMES
Expand All @@ -187,17 +189,43 @@ open class D2Exporter : AbstractDiagramExporter() {
}

override fun writeElements(view: ModelView, elements: List<GroupableElement>, writer: IndentingWriter) {
super.writeElements(view, elements, writer)
elements.asSequence()
.filter { it.group != null }
.distinctBy { it.group }
.forEach { writeGroup(view, it, writer) }
.mapNotNull { it.groupsWithPathsOrNull() }
.flatten()
.distinct()
.forEach {
writeGroup(view, it, writer)
}
elements.sortedBy { it.id }.forEach {
writeElementOrDeploymentNode(view, it, writer)
}
}

private fun writeElementOrDeploymentNode(view: ModelView, element: Element, writer: IndentingWriter) {
if (view is DeploymentView && element is DeploymentNode) {
writeDeploymentNode(view, element, writer)
} else {
writeElement(view, element, writer)
}
}

private fun writeDeploymentNode(view: DeploymentView, deploymentNode: DeploymentNode, writer: IndentingWriter) {
startDeploymentNodeBoundary(view, deploymentNode, writer)
val elements = sequenceOf(
deploymentNode.children.asSequence(),
deploymentNode.infrastructureNodes.asSequence(),
deploymentNode.softwareSystemInstances.asSequence(),
deploymentNode.containerInstances.asSequence()
).flatten()
.filter { it.inViewNotRoot(view) }
.toList()
writeElements(view, elements, writer)
endDeploymentNodeBoundary(view, writer)
}

protected fun writeGroup(view: ModelView, element: GroupableElement, writer: IndentingWriter) {
val groupAbsolutePathInView = element.groupAbsolutePathInView(view) ?: return
NamedObject.build(groupAbsolutePathInView) {
label(element.group)
protected fun writeGroup(view: ModelView, groupWithPath: GroupWithPath, writer: IndentingWriter) {
NamedObject.build(groupWithPath.absolutePathInView(view)) {
label(groupWithPath.name)
withGroupStyle()
}.writeObject(writer)
}
Expand Down Expand Up @@ -291,17 +319,13 @@ open class D2Exporter : AbstractDiagramExporter() {

class StepsAnimationState {
private val metElements: MutableSet<String> = mutableSetOf()
private val metGroups: MutableSet<String> = mutableSetOf()
private val metGroups: MutableSet<GroupWithPath> = mutableSetOf()

fun addElement(element: Element): Boolean = metElements.add(element.id)
fun addGroup(group: String?): Boolean = !group.isNullOrEmpty() && metGroups.add(group)
fun addGroup(group: GroupWithPath): Boolean = metGroups.add(group)

fun ifNewElement(element: Element, block: () -> Unit) {
if (addElement(element)) block()
}

fun ifNewGroup(group: String?, block: () -> Unit) {
if (addGroup(group)) block()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.structurizr.model.DeploymentNode
import com.structurizr.model.Element
import com.structurizr.model.GroupableElement
import com.structurizr.view.*
import io.github.goto1134.structurizr.export.d2.D2Exporter.Companion.STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME
import io.github.goto1134.structurizr.export.d2.model.D2FillPattern
import io.github.goto1134.structurizr.export.d2.model.D2Shape

Expand All @@ -28,7 +29,36 @@ val Element.d2Id get() = "container_$id"

val Element.d2GroupId get() = (this as? GroupableElement)?.d2GroupId

val GroupableElement.d2GroupId get() = group?.takeUnless { it.isEmpty() }?.let { "\"group_$it\"" }
val GroupableElement.d2GroupId: String? get() = parentGroupIdSequenceOrNull()?.joinToString(".")

fun GroupableElement.parentGroupIdSequenceOrNull() = parentGroupSequenceOrNull()?.map { "\"group_$it\"" }

fun GroupableElement.parentGroupSequenceOrNull(): Sequence<String>? {
val group = group?.takeUnless { it.isEmpty() } ?: return null
return when (val separator = model.properties[STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME]) {
null -> sequenceOf(group)
else -> group.splitToSequence(separator)
}
}

data class GroupWithPath(val parent: Element?, val relativePath: String, val name: String) {
fun absolutePathInView(view: ModelView) = buildString {
parent?.absolutePathInView(view)?.let { append(it, ".") }
append(relativePath)
}
}

fun GroupableElement.groupsWithPathsOrNull() =
parentGroupSequenceOrNull()?.scan(GroupWithPath(parent, "", "")) { wrapper, groupName ->
GroupWithPath(
parent = parent,
relativePath = buildString {
if (wrapper.relativePath.isNotEmpty()) append(wrapper.relativePath, ".")
append("\"group_", groupName, "\"")
},
name = groupName
)
}?.drop(1)

val Element.hasMultipleInstances get() = this is DeploymentNode && "1" != instances

Expand All @@ -54,8 +84,3 @@ fun Element.absolutePathInView(view: ModelView): String {
.reversed()
.joinToString(separator = ".") { it.nameInView(view) }
}

fun GroupableElement.groupAbsolutePathInView(view: ModelView): String? {
return listOfNotNull(parent?.absolutePathInView(view), d2GroupId).joinToString(".")
.takeUnless(String::isBlank)
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ internal class D2ExporterTest {
assertAllDiagramsMatch("groups", diagrams)
}

@Test
fun test_NestedGroupsExample() {
val workspace = WorkspaceUtils.loadWorkspaceFromJson(testFile("groups-nested.json"))
ThemeUtils.loadThemes(workspace)
val diagrams = D2Exporter().export(workspace)
assertEquals(1, diagrams.size)
assertAllDiagramsMatch("groups-nested", diagrams)
}

@Test
fun test_AnimatedRelation() {
val workspace = WorkspaceUtils.loadWorkspaceFromJson(testFile("animated-relation/workspace.json"))
Expand Down
47 changes: 47 additions & 0 deletions lib/src/test/resources/groups-nested.dsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
workspace {

model {
properties {
structurizr.groupSeparator /
}

group "Organisation" {
group "Department A" {
a = softwareSystem "A" {
group "Capability 1" {
group "Service A" {
container "A API"
container "A Database"
}
group "Service B" {
container "B API"
container "B Database"
}
}
}
}

group "Department B" {
b = softwareSystem "B"
}

c = softwareSystem "C"
}

enterprise "Enterprise" {
group "Department A" {
group "Team 1" {
d = softwareSystem "D"
}
}
}
}

views {
systemLandscape {
include *
autolayout
}
}

}
121 changes: 121 additions & 0 deletions lib/src/test/resources/groups-nested.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{
"id" : 0,
"name" : "Name",
"description" : "Description",
"properties" : {
"structurizr.dsl" : "d29ya3NwYWNlIHsKCiAgICBtb2RlbCB7CiAgICAgICAgcHJvcGVydGllcyB7CiAgICAgICAgICAgIHN0cnVjdHVyaXpyLmdyb3VwU2VwYXJhdG9yIC8KICAgICAgICB9CgogICAgICAgIGdyb3VwICJPcmdhbmlzYXRpb24iIHsKICAgICAgICAgICAgZ3JvdXAgIkRlcGFydG1lbnQgQSIgewogICAgICAgICAgICAgICAgYSA9IHNvZnR3YXJlU3lzdGVtICJBIiB7CiAgICAgICAgICAgICAgICAgICAgZ3JvdXAgIkNhcGFiaWxpdHkgMSIgewogICAgICAgICAgICAgICAgICAgICAgICBncm91cCAiU2VydmljZSBBIiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjb250YWluZXIgIkEgQVBJIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgY29udGFpbmVyICJBIERhdGFiYXNlIgogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgICAgIGdyb3VwICJTZXJ2aWNlIEIiIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRhaW5lciAiQiBBUEkiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjb250YWluZXIgIkIgRGF0YWJhc2UiCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGdyb3VwICJEZXBhcnRtZW50IEIiIHsKICAgICAgICAgICAgICAgIGIgPSBzb2Z0d2FyZVN5c3RlbSAiQiIKICAgICAgICAgICAgfQoKICAgICAgICAgICAgYyA9IHNvZnR3YXJlU3lzdGVtICJDIgogICAgICAgIH0KCiAgICAgICAgZW50ZXJwcmlzZSAiRW50ZXJwcmlzZSIgewogICAgICAgICAgICBncm91cCAiRGVwYXJ0bWVudCBBIiB7CiAgICAgICAgICAgICAgICBncm91cCAiVGVhbSAxIiB7CiAgICAgICAgICAgICAgICAgICAgZCA9IHNvZnR3YXJlU3lzdGVtICJEIgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQogICAgfQoKICAgIHZpZXdzIHsKICAgICAgICBzeXN0ZW1MYW5kc2NhcGUgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICAgICAgYXV0b2xheW91dAogICAgICAgIH0KICAgIH0KCn0K"
},
"configuration" : { },
"model" : {
"enterprise" : {
"name" : "Enterprise"
},
"softwareSystems" : [ {
"id" : "1",
"tags" : "Element,Software System",
"properties" : {
"structurizr.dsl.identifier" : "a"
},
"name" : "A",
"group" : "Organisation/Department A",
"location" : "External",
"containers" : [ {
"id" : "4",
"tags" : "Element,Container",
"name" : "B API",
"group" : "Capability 1/Service B",
"documentation" : { }
}, {
"id" : "2",
"tags" : "Element,Container",
"name" : "A API",
"group" : "Capability 1/Service A",
"documentation" : { }
}, {
"id" : "5",
"tags" : "Element,Container",
"name" : "B Database",
"group" : "Capability 1/Service B",
"documentation" : { }
}, {
"id" : "3",
"tags" : "Element,Container",
"name" : "A Database",
"group" : "Capability 1/Service A",
"documentation" : { }
} ],
"documentation" : { }
}, {
"id" : "6",
"tags" : "Element,Software System",
"properties" : {
"structurizr.dsl.identifier" : "b"
},
"name" : "B",
"group" : "Organisation/Department B",
"location" : "External",
"documentation" : { }
}, {
"id" : "7",
"tags" : "Element,Software System",
"properties" : {
"structurizr.dsl.identifier" : "c"
},
"name" : "C",
"group" : "Organisation",
"location" : "External",
"documentation" : { }
}, {
"id" : "8",
"tags" : "Element,Software System",
"properties" : {
"structurizr.dsl.identifier" : "d"
},
"name" : "D",
"group" : "Department A/Team 1",
"location" : "Internal",
"documentation" : { }
} ],
"properties" : {
"structurizr.groupSeparator" : "/"
}
},
"documentation" : { },
"views" : {
"systemLandscapeViews" : [ {
"key" : "SystemLandscape",
"order" : 1,
"automaticLayout" : {
"implementation" : "Graphviz",
"rankDirection" : "TopBottom",
"rankSeparation" : 300,
"nodeSeparation" : 300,
"edgeSeparation" : 0,
"vertices" : false
},
"enterpriseBoundaryVisible" : true,
"elements" : [ {
"id" : "1",
"x" : 0,
"y" : 0
}, {
"id" : "6",
"x" : 0,
"y" : 0
}, {
"id" : "7",
"x" : 0,
"y" : 0
}, {
"id" : "8",
"x" : 0,
"y" : 0
} ]
} ],
"configuration" : {
"branding" : { },
"styles" : { },
"terminology" : { }
}
}
}
Loading

0 comments on commit 7273373

Please sign in to comment.