Skip to content

Commit 768513e

Browse files
committed
feat(model-server): add lazy loading for Content Explorer
1 parent c58976b commit 768513e

File tree

3 files changed

+130
-43
lines changed

3 files changed

+130
-43
lines changed

model-server/src/main/kotlin/org/modelix/model/server/handlers/ContentExplorer.kt

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import io.ktor.server.application.Application
55
import io.ktor.server.application.call
66
import io.ktor.server.html.respondHtml
77
import io.ktor.server.html.respondHtmlTemplate
8+
import io.ktor.server.request.receive
89
import io.ktor.server.response.respondRedirect
910
import io.ktor.server.response.respondText
1011
import io.ktor.server.routing.get
12+
import io.ktor.server.routing.post
1113
import io.ktor.server.routing.routing
1214
import kotlinx.html.BODY
1315
import kotlinx.html.FlowContent
@@ -24,6 +26,7 @@ import kotlinx.html.li
2426
import kotlinx.html.link
2527
import kotlinx.html.script
2628
import kotlinx.html.small
29+
import kotlinx.html.stream.appendHTML
2730
import kotlinx.html.style
2831
import kotlinx.html.table
2932
import kotlinx.html.td
@@ -67,20 +70,46 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
6770
get("/content/{versionHash}/") {
6871
val versionHash = call.parameters["versionHash"]
6972
if (versionHash.isNullOrEmpty()) {
70-
call.respondText("version not found", status = HttpStatusCode.InternalServerError)
73+
call.respondText("version not found", status = HttpStatusCode.BadRequest)
7174
return@get
7275
}
76+
7377
val tree = CLVersion.loadFromHash(versionHash, client.storeCache).getTree()
7478
val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree))
79+
7580
call.respondHtmlTemplate(PageWithMenuBar("repos/", "../..")) {
7681
headContent {
7782
title("Content Explorer")
7883
link("../../public/content-explorer.css", rel = "stylesheet")
7984
script("text/javascript", src = "../../public/content-explorer.js") {}
8085
}
81-
bodyContent { contentPageBody(rootNode, versionHash) }
86+
bodyContent { contentPageBody(rootNode, versionHash, emptySet()) }
8287
}
8388
}
89+
post("/content/{versionHash}/") {
90+
val versionHash = call.parameters["versionHash"]
91+
if (versionHash.isNullOrEmpty()) {
92+
call.respondText("version not found", status = HttpStatusCode.BadRequest)
93+
return@post
94+
}
95+
val expandedNodes = call.receive<ContentExplorerExpandedNodes>()
96+
97+
val tree = CLVersion.loadFromHash(versionHash, client.storeCache).getTree()
98+
val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree))
99+
100+
var expandedNodeIds = expandedNodes.expandedNodeIds
101+
if (expandedNodes.expandAll) {
102+
expandedNodeIds = expandedNodeIds + collectExpandableChildNodes(rootNode, expandedNodes.expandedNodeIds)
103+
}
104+
105+
call.respondText(
106+
buildString {
107+
appendHTML().ul("treeRoot") {
108+
nodeItem(rootNode, expandedNodeIds)
109+
}
110+
},
111+
)
112+
}
84113
get("/content/{versionHash}/{nodeId}/") {
85114
val id = call.parameters["nodeId"]!!.toLong()
86115
var found: PNodeAdapter? = null
@@ -100,7 +129,21 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
100129
}
101130
}
102131

103-
private fun FlowContent.contentPageBody(rootNode: PNodeAdapter, versionHash: String) {
132+
private fun collectExpandableChildNodes(node: PNodeAdapter, expandedNodeIds: Set<String>): Set<String> {
133+
if (expandedNodeIds.contains(node.nodeId.toString())) {
134+
val newIds = mutableSetOf<String>()
135+
for (child in node.allChildren) {
136+
newIds.addAll(collectExpandableChildNodes(child as PNodeAdapter, expandedNodeIds))
137+
}
138+
return newIds
139+
}
140+
if (node.allChildren.toList().isNotEmpty()) {
141+
return setOf(node.nodeId.toString())
142+
}
143+
return emptySet()
144+
}
145+
146+
private fun FlowContent.contentPageBody(rootNode: PNodeAdapter, versionHash: String, expandedNodeIds: Set<String>) {
104147
h1 { +"Model Server Content" }
105148
small {
106149
style = "color: #888; text-align: center; margin-bottom: 15px;"
@@ -120,18 +163,19 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
120163
div {
121164
id = "treeWrapper"
122165
ul("treeRoot") {
123-
nodeItem(rootNode)
166+
nodeItem(rootNode, expandedNodeIds)
124167
}
125168
}
126169
div {
127170
id = "nodeInspector"
128171
}
129172
}
130173

131-
private fun UL.nodeItem(node: PNodeAdapter) {
174+
private fun UL.nodeItem(node: PNodeAdapter, expandedNodeIds: Set<String>) {
132175
li("nodeItem") {
176+
val expanded = expandedNodeIds.contains(node.nodeId.toString())
133177
if (node.allChildren.toList().isNotEmpty()) {
134-
div("expander") { unsafe { +"&#x25B6;" } }
178+
div(if (expanded) "expander expander-expanded" else "expander") { unsafe { +"&#x25B6;" } }
135179
}
136180
div("nameField") {
137181
attributes["data-nodeid"] = node.nodeId.toString()
@@ -157,10 +201,12 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
157201
}
158202
}
159203
}
160-
div("nested") {
161-
ul("nodeTree") {
162-
for (child in node.allChildren) {
163-
nodeItem(child as PNodeAdapter)
204+
div(if (expanded) "nested active" else "nested") {
205+
if (expanded) {
206+
ul("nodeTree") {
207+
for (child in node.allChildren) {
208+
nodeItem(child as PNodeAdapter, expandedNodeIds)
209+
}
164210
}
165211
}
166212
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright (c) 2023.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.modelix.model.server.handlers
18+
19+
import kotlinx.serialization.Serializable
20+
21+
@Serializable
22+
data class ContentExplorerExpandedNodes(val expandedNodeIds: Set<String>, val expandAll: Boolean)

model-server/src/main/resources/public/content-explorer.js

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,54 +4,73 @@ async function createInspectorDetails(nodeId) {
44
nodeInspector.innerHTML = await response.text();
55
nodeInspector.style.display = 'block';
66
}
7-
document.addEventListener('DOMContentLoaded', () => {
8-
var expander = document.getElementsByClassName('expander');
9-
var nameField = document.getElementsByClassName('nameField');
10-
var expandAllBtn = document.getElementById('expandAllBtn');
11-
var collapseAllBtn = document.getElementById('collapseAllBtn');
127

8+
function getExpandedNodeIds() {
9+
const expandedElements = document.getElementsByClassName('expander-expanded');
10+
return Array.from(expandedElements).map(
11+
element => element.nextElementSibling?.getAttribute('data-nodeid'));
12+
}
13+
14+
function sendExpandNodeRequest(expandAll) {
15+
const xhr = new XMLHttpRequest();
16+
xhr.onreadystatechange = () => {
17+
if (xhr.readyState === 4 && xhr.status === 200) {
18+
document.getElementById('treeWrapper').innerHTML = xhr.response;
19+
addContentExplorerClickListeners();
20+
}
21+
}
22+
xhr.open("POST", window.location.href, true);
23+
xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
24+
xhr.send(JSON.stringify({"expandedNodeIds": getExpandedNodeIds(), "expandAll" : expandAll}));
25+
}
26+
27+
function addContentExplorerClickListeners() {
28+
29+
const nameField = document.getElementsByClassName('nameField');
1330
for (let i = 0; i < nameField.length; i++) {
14-
nameField[i].addEventListener('click', function() {
15-
let isSelected = this.classList.contains('selectedNameField');
16-
if (isSelected) {
31+
nameField[i].addEventListener('click', function () {
32+
let isSelected = this.classList.contains('selectedNameField');
33+
if (isSelected) {
1734
document.getElementById('nodeInspector').style.display = 'none';
18-
} else {
35+
} else {
1936
createInspectorDetails(this.dataset.nodeid);
20-
}
21-
let selected = document.getElementsByClassName('selectedNameField');
22-
for (let j = 0; j < selected.length; j++) {
37+
}
38+
let selected = document.getElementsByClassName('selectedNameField');
39+
for (let j = 0; j < selected.length; j++) {
2340
selected[j].classList.remove('selectedNameField');
24-
}
25-
if (!isSelected) {
41+
}
42+
if (!isSelected) {
2643
this.classList.add('selectedNameField');
27-
}
44+
}
2845
});
2946
}
3047

48+
const expander = document.getElementsByClassName('expander');
3149
for (let i = 0; i < expander.length; i++) {
32-
expander[i].addEventListener('click', function() {
33-
this.parentElement.querySelector(".nested").classList.toggle('active');
50+
expander[i].addEventListener('click', function () {
51+
this.parentElement.querySelector('.nested').classList.toggle('active');
3452
this.classList.toggle('expander-expanded');
53+
localStorage.setItem('scrollY', String(window.scrollY));
54+
sendExpandNodeRequest(false);
3555
});
3656
}
57+
}
58+
59+
document.addEventListener('DOMContentLoaded', () => {
60+
61+
const scrollY = localStorage.getItem('scrollY');
62+
if (scrollY) {
63+
window.scrollTo(0, Number(scrollY));
64+
localStorage.removeItem('scrollY');
65+
}
3766

38-
expandAllBtn.addEventListener('click', function () {
39-
var nested = document.getElementsByClassName("nested")
40-
for (let i=0; i < nested.length; i++) {
41-
nested[i].classList.add('active');
42-
}
43-
for (let i = 0; i < expander.length; i++) {
44-
expander[i].classList.add('expander-expanded')
45-
}
67+
addContentExplorerClickListeners();
68+
69+
document.getElementById('expandAllBtn').addEventListener('click', function () {
70+
sendExpandNodeRequest(true);
4671
});
4772

48-
collapseAllBtn.addEventListener('click', function () {
49-
var nested = document.getElementsByClassName('nested')
50-
for (let i=0; i < nested.length; i++) {
51-
nested[i].classList.remove('active');
52-
}
53-
for (let i = 0; i < expander.length; i++) {
54-
expander[i].classList.remove('expander-expanded')
55-
}
73+
document.getElementById('collapseAllBtn').addEventListener('click', function () {
74+
window.location.href = window.location.pathname
5675
});
5776
});

0 commit comments

Comments
 (0)