diff --git a/README.md b/README.md index 82c171b6..e862ce91 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ architecture model: | `generatr.search.language` | Indexing/stemming language for the search index. See [Lunr language support](https://github.com/olivernn/lunr-languages) | `en` | `nl` | | `generatr.markdown.flexmark.extensions` | Additional extensions to the markdown generator to add new markdown capabilities. [More Details](https://avisi-cloud.github.io/structurizr-site-generatr/main/extended-markdown-features/) | Tables | `Tables,Admonition` | | `generatr.svglink.target` | Specifies the link target for element links in the exported svg | `_top` | `_self` | +| `generatr.site.nestGroups` | Will show software systems in the left side navigator in collapsable groups | `false` | `true` | See the included example for usage of some those properties in the diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt index 6909c745..cde19af0 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt @@ -19,6 +19,8 @@ fun copySiteWideAssets(exportDir: File) { copySiteWideAsset(exportDir, "/css/admonition.css") copySiteWideAsset(exportDir, "/js/admonition.js") copySiteWideAsset(exportDir, "/js/reformat-mermaid.js") + copySiteWideAsset(exportDir, "/css/treeview.css") + copySiteWideAsset(exportDir, "/js/treeview.js") } private fun copySiteWideAsset(exportDir: File, asset: String) { diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuNodeViewModel.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuNodeViewModel.kt new file mode 100644 index 00000000..3bc1351f --- /dev/null +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuNodeViewModel.kt @@ -0,0 +1,3 @@ +package nl.avisi.structurizr.site.generatr.site.model + +data class MenuNodeViewModel(val name: String, val children: List) diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModel.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModel.kt index 6cb888e6..971d9e39 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModel.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModel.kt @@ -27,4 +27,34 @@ class MenuViewModel(generatorContext: GeneratorContext, private val pageViewMode private fun createMenuItem(title: String, href: String, exact: Boolean = true) = LinkViewModel(pageViewModel, title, href, exact) + + fun softwareSystemNodes(): MenuNodeViewModel { + data class MutableMenuNode(val name: String, val children: MutableList) { + fun toMenuNode(): MenuNodeViewModel = MenuNodeViewModel(name, children.map { it.toMenuNode() }) + } + + val rootNode = MutableMenuNode("", mutableListOf()) + + softwareSystemPaths.forEach { path -> + var currentNode = rootNode + path.split(delimiter).forEach { part -> + val existingNode = currentNode.children.find { it.name == part } + currentNode = if (existingNode == null) { + val newNode = MutableMenuNode(part, mutableListOf()) + currentNode.children.add(newNode) + newNode + } else { + existingNode + } + } + } + + return rootNode.toMenuNode() + } + + private val softwareSystemPaths = generatorContext.workspace.model.includedSoftwareSystems + .map { it.group + "/" + it.name } + .sortedBy { it.lowercase() } + + private val delimiter = '/' } diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt index 9ea1c83d..6c779a4b 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt @@ -20,6 +20,7 @@ abstract class PageViewModel(protected val generatorContext: GeneratorContext) { val includeAdmonition = flexmarkConfig.selectedExtensionMap.containsKey("Admonition") val includeKatex = flexmarkConfig.selectedExtensionMap.containsKey("GitLab") val configuration = generatorContext.workspace.views.configuration.properties + val includeTreeview = configuration.getOrDefault("generatr.site.nestGroups", "false").toBoolean() abstract val url: String abstract val pageSubTitle: String diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Menu.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Menu.kt index fe6c2a6e..983e445f 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Menu.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Menu.kt @@ -1,13 +1,15 @@ package nl.avisi.structurizr.site.generatr.site.views import kotlinx.html.* +import nl.avisi.structurizr.site.generatr.site.GeneratorContext import nl.avisi.structurizr.site.generatr.site.model.LinkViewModel +import nl.avisi.structurizr.site.generatr.site.model.MenuNodeViewModel import nl.avisi.structurizr.site.generatr.site.model.MenuViewModel -fun DIV.menu(viewModel: MenuViewModel) { +fun DIV.menu(viewModel: MenuViewModel, nestGroups:Boolean) { aside(classes = "menu p-3") { generalSection(viewModel.generalItems) - softwareSystemsSection(viewModel.softwareSystemItems) + softwareSystemsSection(viewModel, nestGroups) } } @@ -16,9 +18,15 @@ private fun ASIDE.generalSection(items: List) { menuItemLinks(items) } -private fun ASIDE.softwareSystemsSection(items: List) { +private fun ASIDE.softwareSystemsSection(viewModel: MenuViewModel, nestGroups:Boolean) { p(classes = "menu-label") { +"Software systems" } - menuItemLinks(items) + if(nestGroups){ + ul(classes = "listree menu-list has-site-branding"){ + buildHtmlTree(viewModel.softwareSystemNodes(), viewModel).invoke(this) + } + } else { + menuItemLinks(viewModel.softwareSystemItems) + } } private fun ASIDE.menuItemLinks(items: List) { @@ -30,3 +38,42 @@ private fun ASIDE.menuItemLinks(items: List) { } } } + +private fun buildHtmlTree(node: MenuNodeViewModel, viewModel: MenuViewModel): UL.() -> Unit = { + if (node.name.isNotEmpty() && node.children.isEmpty()) { + val itemLink = viewModel.softwareSystemItems.find { it.title == node.name } + li { + if (itemLink != null) { + link(itemLink) + } + } + } + + if (node.name.isNotEmpty() && node.children.isNotEmpty()) { + li { + if(node.name == "null"){ + div(classes = "listree-submenu-heading") { + +"No Group" + } + } else { + div(classes = "listree-submenu-heading") { + +node.name + } + } + + ul(classes = "listree-submenu-items") { + for (child in node.children) { + buildHtmlTree(child,viewModel).invoke(this) + } + } + } + } + + if (node.name.isEmpty() && node.children.isNotEmpty()) { + ul(classes = "listree-submenu-items") { + for (child in node.children) { + buildHtmlTree(child, viewModel).invoke(this) + } + } + } +} diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt index a84f25fc..3eebcc3b 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt @@ -21,6 +21,9 @@ private fun HTML.headFragment(viewModel: PageViewModel) { link(rel = "stylesheet", href = "../" + "/style.css".asUrlToFile(viewModel.url)) link(rel = "stylesheet", href = "./" + "/style-branding.css".asUrlToFile(viewModel.url)) + if (viewModel.includeTreeview) + link(rel = "stylesheet", href = "../" + "/treeview.css".asUrlToFile(viewModel.url)) + if (viewModel.includeAdmonition) markdownAdmonitionStylesheet(viewModel) @@ -51,7 +54,7 @@ private fun HTML.bodyFragment(viewModel: PageViewModel, block: DIV.() -> Unit) { div(classes = "site-layout") { id = "site" - menu(viewModel.menu) + menu(viewModel.menu, viewModel.includeTreeview) div(classes = "container is-fluid has-background-white") { block() } @@ -61,6 +64,12 @@ private fun HTML.bodyFragment(viewModel: PageViewModel, block: DIV.() -> Unit) { updateSiteErrorHero() if (viewModel.includeAdmonition) markdownAdmonitionScript(viewModel) + mermaidScript(viewModel) + + if (viewModel.includeTreeview){ + script(type = ScriptType.textJavaScript,src = "../" + "/treeview.js".asUrlToFile(viewModel.url)) { } + script(type = ScriptType.textJavaScript) { unsafe { +"listree();" } } + } } } diff --git a/src/main/resources/assets/css/treeview.css b/src/main/resources/assets/css/treeview.css new file mode 100644 index 00000000..9473fbfa --- /dev/null +++ b/src/main/resources/assets/css/treeview.css @@ -0,0 +1,36 @@ +.listree-submenu-heading { + cursor: pointer; + padding: .5em .75em; +} + +ul.listree { + list-style: none +} + +ul.listree-submenu-items { + list-style: none; + white-space: nowrap; + padding-left: 10px +} + +div.listree-submenu-heading.collapsed:before { + content: "\25B6"; + margin-right: 4px +} + +div.listree-submenu-heading.expanded:before { + content: "\25BC"; + margin-right: 4px +} + +.scrollable-menu { + height: auto; + max-width: 800px; + overflow-y: hidden +} + +.menu-list li ul { + border-left: 0; + margin: .25em; + padding-left: .75em; +} diff --git a/src/main/resources/assets/js/treeview.js b/src/main/resources/assets/js/treeview.js new file mode 100644 index 00000000..33c21fe7 --- /dev/null +++ b/src/main/resources/assets/js/treeview.js @@ -0,0 +1,65 @@ +function listree() { + + const subMenuHeadingClass = "listree-submenu-heading"; + const expandedClass = "expanded"; + const collapsedClass = "collapsed"; + const activeClass = "is-active"; + const subMenuHeadings = document.getElementsByClassName(subMenuHeadingClass); + + Array + .from(subMenuHeadings) + .forEach(function(subMenuHeading) { + // Collapse all the subMenuHeadings while searching for is-active class + let foundActive = false; + subMenuHeading.classList.add(collapsedClass); + subMenuHeading.nextElementSibling.style.display = "none"; + + // Check if this sub-menu heading is active + const liElements = subMenuHeading.nextElementSibling.children; + for (let i = 0; i < liElements.length; i++) { + if (liElements[i].hasChildNodes()) { + if (liElements[i].children[0].tagName === "A") { + if (liElements[i].children[0].classList.contains(activeClass)) { + foundActive = true; + break; + } + } + } + } + + // Expand all parent sub-menus until root menu is reached + if (foundActive) { + subMenuHeading.classList.remove(collapsedClass); + subMenuHeading.classList.add(expandedClass); + subMenuHeading.nextElementSibling.style.display = "block"; + + let currentSubMenu = subMenuHeading.parentElement; + while (currentSubMenu !== null && !currentSubMenu.classList.contains("listree")) { + if(currentSubMenu.tagName === "UL"){ + if(currentSubMenu.previousElementSibling != null) { + currentSubMenu.previousElementSibling.classList.remove(collapsedClass); + currentSubMenu.previousElementSibling.classList.add(expandedClass); + currentSubMenu.style.display = "block"; + } + } + currentSubMenu = currentSubMenu.parentElement + } + } + + // Add the eventlistener to all subMenuHeadings + subMenuHeading.addEventListener("click", function(event) { + event.preventDefault(); + const subMenuList = event.target.nextElementSibling; + if (subMenuList.style.display === "none") { + subMenuHeading.classList.remove(collapsedClass); + subMenuHeading.classList.add(expandedClass); + subMenuHeading.nextElementSibling.style.display = "block"; + } else { + subMenuHeading.classList.remove(expandedClass); + subMenuHeading.classList.add(collapsedClass); + subMenuHeading.nextElementSibling.style.display = "none"; + } + event.stopPropagation(); + }); + }); +} diff --git a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModelTest.kt b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModelTest.kt index adc62157..5660784d 100644 --- a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModelTest.kt +++ b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/MenuViewModelTest.kt @@ -136,6 +136,25 @@ class MenuViewModelTest : ViewModelTest() { } } + @Test + fun `show nested groups in software systems list`() { + val generatorContext = generatorContext(branches = listOf("main", "branch-2"), currentBranch = "main") + generatorContext.workspace.views.configuration.addProperty("generatr.site.nestGroups","true") + generatorContext.workspace.model.addSoftwareSystem("System 1").group = "Group 1" + generatorContext.workspace.model.addSoftwareSystem("System 2").group = "Group 1" + generatorContext.workspace.model.addSoftwareSystem("System 3").group = "Group 2" + generatorContext.workspace.model.addSoftwareSystem("System 4").group = "Group 1/Group 3" + + MenuViewModel(generatorContext, createPageViewModel(generatorContext, url = HomePageViewModel.url())) + .let { + assertThat(it.softwareSystemNodes().children).hasSize(2) + assertThat(it.softwareSystemNodes().children[0].name).isEqualTo("Group 1") + assertThat(it.softwareSystemNodes().children[0].children).hasSize(3) + assertThat(it.softwareSystemNodes().children[0].children[0].name).isEqualTo("Group 3") + assertThat(it.softwareSystemNodes().children[0].children[0].children[0].name).isEqualTo("System 4") + } + } + private fun createPageViewModel(generatorContext: GeneratorContext, url: String = "/master/page"): PageViewModel { return object : PageViewModel(generatorContext) { override val url = url