Skip to content

Commit

Permalink
Add support for nested groups in side bar navigation (#180)
Browse files Browse the repository at this point in the history
* Add nested groups support in side nav

* Update README.md to reference the new option.

* minor change to fix case on configuration property to match the reference in the readme

* Minor update to the nested menu css to bring it closer to the standard non-nested list.

* Implemented suggested changes

* Added a test for MenuViewModel.SoftwareSystemsNodes

* Fix some whitespace issues and reformat table in readme file.

* Fix some additional whitespace issues
  • Loading branch information
qtzar authored Oct 13, 2023
1 parent 5d302c4 commit fe854fa
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package nl.avisi.structurizr.site.generatr.site.model

data class MenuNodeViewModel(val name: String, val children: List<MenuNodeViewModel>)
Original file line number Diff line number Diff line change
Expand Up @@ -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<MutableMenuNode>) {
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 = '/'
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}

Expand All @@ -16,9 +18,15 @@ private fun ASIDE.generalSection(items: List<LinkViewModel>) {
menuItemLinks(items)
}

private fun ASIDE.softwareSystemsSection(items: List<LinkViewModel>) {
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<LinkViewModel>) {
Expand All @@ -30,3 +38,42 @@ private fun ASIDE.menuItemLinks(items: List<LinkViewModel>) {
}
}
}

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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
}
Expand All @@ -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();" } }
}
}
}
36 changes: 36 additions & 0 deletions src/main/resources/assets/css/treeview.css
Original file line number Diff line number Diff line change
@@ -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;
}
65 changes: 65 additions & 0 deletions src/main/resources/assets/js/treeview.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit fe854fa

Please sign in to comment.