Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nested Groups in sideNav #180

Merged
merged 10 commits into from
Oct 13, 2023
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,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` |
qtzar marked this conversation as resolved.
Show resolved Hide resolved
| `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.nav.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
Expand Up @@ -27,4 +27,30 @@ class MenuViewModel(generatorContext: GeneratorContext, private val pageViewMode

private fun createMenuItem(title: String, href: String, exact: Boolean = true) =
LinkViewModel(pageViewModel, title, href, exact)

data class Node(val name: String, val children: MutableList<Node>)
qtzar marked this conversation as resolved.
Show resolved Hide resolved

val nestedSoftwareSystems = generatorContext.workspace.model.includedSoftwareSystems
qtzar marked this conversation as resolved.
Show resolved Hide resolved
.map {it.group + "/" + it.name}
.sortedBy {it.lowercase()}

fun buildTree(data: List<String>, delimiter: Char):MutableList<Node> {
val rootNode = Node("", mutableListOf())
for (path in data) {
val parts = path.split(delimiter)
var currentNode = rootNode

for (part in parts) {
val existingNode = currentNode.children.find { it.name == part }
currentNode = if (existingNode == null) {
val newNode = Node(part, mutableListOf())
currentNode.children.add(newNode)
newNode
} else {
existingNode
}
}
}
return rootNode.children
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ abstract class PageViewModel(protected val generatorContext: GeneratorContext) {
val headerBar by lazy { HeaderBarViewModel(this, generatorContext) }
val menu by lazy { MenuViewModel(generatorContext, this) }
val includeAutoReloading = generatorContext.serving
val flexmarkConfig by lazy {
buildFlexmarkConfig(generatorContext)

val flexmarkConfig by lazy {
buildFlexmarkConfig(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.nav.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,14 @@
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.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 +17,18 @@ 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){
// Display SoftwareSystems as a nested lists
val rootNode = MenuViewModel.Node("", viewModel.buildTree(viewModel.nestedSoftwareSystems, "/".toCharArray()[0]))
qtzar marked this conversation as resolved.
Show resolved Hide resolved
ul(classes = "listree menu-list has-site-branding"){
buildHtmlTree(rootNode, viewModel).invoke(this)
}
} else {
// Display SoftwareSystems as a flat list
menuItemLinks(viewModel.softwareSystemItems)
}
}

private fun ASIDE.menuItemLinks(items: List<LinkViewModel>) {
Expand All @@ -30,3 +40,43 @@ private fun ASIDE.menuItemLinks(items: List<LinkViewModel>) {
}
}
}

private fun buildHtmlTree(node: MenuViewModel.Node, viewModel: MenuViewModel): UL.() -> Unit = {
qtzar marked this conversation as resolved.
Show resolved Hide resolved

qtzar marked this conversation as resolved.
Show resolved Hide resolved
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()) {
qtzar marked this conversation as resolved.
Show resolved Hide resolved
ul(classes = "listree-submenu-items") {
for (child in node.children) {
buildHtmlTree(child, viewModel).invoke(this)
}
}
}

qtzar marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ private fun HTML.headFragment(viewModel: PageViewModel) {
href = "./" + "/style-branding.css".asUrlToFile(viewModel.url)
)

if (viewModel.includeAdmonition)
if(viewModel.includeTreeview){
qtzar marked this conversation as resolved.
Show resolved Hide resolved
link(
rel = "stylesheet",
href = "../" + "/treeview.css".asUrlToFile(viewModel.url)
)
}

if (viewModel.includeAdmonition)
markdownAdmonitionStylesheet(viewModel)

if (viewModel.includeKatex)
Expand All @@ -54,7 +61,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 @@ -64,6 +71,19 @@ private fun HTML.bodyFragment(viewModel: PageViewModel, block: DIV.() -> Unit) {
updateSiteErrorHero()
if (viewModel.includeAdmonition)
markdownAdmonitionScript(viewModel)
if (viewModel.includeTreeview)
qtzar marked this conversation as resolved.
Show resolved Hide resolved

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();
});
});
}