Skip to content

Commit 5197919

Browse files
committed
feat(model-server): add option to expand to a specific node in the ContentExplorer
1 parent 6283745 commit 5197919

File tree

5 files changed

+127
-6
lines changed

5 files changed

+127
-6
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package org.modelix.model.server.handlers
1818

1919
import io.ktor.http.HttpStatusCode
2020
import org.modelix.model.lazy.BranchReference
21+
import org.modelix.model.persistent.SerializationUtil
2122

2223
/**
2324
* A namespace for problems we use as the first part of every `type` in an application/problem+json response.
@@ -101,6 +102,21 @@ class VersionNotFoundException(versionHash: String, cause: Throwable? = null) :
101102
cause = cause,
102103
)
103104

105+
/**
106+
* A [HttpException] indicating that a node was not found.
107+
*
108+
* @param nodeId id of the missing node
109+
* @param cause The causing exception for the bad request or null if none.
110+
*/
111+
class NodeNotFoundException(nodeId: Long, cause: Throwable? = null) :
112+
HttpException(
113+
HttpStatusCode.NotFound,
114+
title = "Node not found",
115+
details = "Node with id '${SerializationUtil.longToHex(nodeId)}' ('$nodeId') doesn't exist",
116+
type = "/problems/node-not-found",
117+
cause = cause,
118+
)
119+
104120
/**
105121
* An [HttpException] indicating that a provided repository name is not valid.
106122
*

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

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,29 @@ import io.ktor.server.routing.routing
3030
import kotlinx.html.BODY
3131
import kotlinx.html.FlowContent
3232
import kotlinx.html.UL
33+
import kotlinx.html.a
3334
import kotlinx.html.b
3435
import kotlinx.html.body
3536
import kotlinx.html.br
3637
import kotlinx.html.button
38+
import kotlinx.html.classes
3739
import kotlinx.html.div
40+
import kotlinx.html.getForm
3841
import kotlinx.html.h1
3942
import kotlinx.html.h3
4043
import kotlinx.html.id
44+
import kotlinx.html.label
4145
import kotlinx.html.li
4246
import kotlinx.html.link
4347
import kotlinx.html.script
4448
import kotlinx.html.small
4549
import kotlinx.html.stream.appendHTML
4650
import kotlinx.html.style
51+
import kotlinx.html.submitInput
4752
import kotlinx.html.table
4853
import kotlinx.html.tbody
4954
import kotlinx.html.td
55+
import kotlinx.html.textInput
5056
import kotlinx.html.th
5157
import kotlinx.html.thead
5258
import kotlinx.html.title
@@ -61,10 +67,12 @@ import org.modelix.model.api.PNodeAdapter
6167
import org.modelix.model.api.TreePointer
6268
import org.modelix.model.client.IModelClient
6369
import org.modelix.model.lazy.BranchReference
70+
import org.modelix.model.lazy.CLTree
6471
import org.modelix.model.lazy.CLVersion
6572
import org.modelix.model.lazy.RepositoryId
6673
import org.modelix.model.server.ModelServerPermissionSchema
6774
import org.modelix.model.server.handlers.IRepositoriesManager
75+
import org.modelix.model.server.handlers.NodeNotFoundException
6876
import org.modelix.model.server.templates.PageWithMenuBar
6977
import kotlin.collections.set
7078

@@ -109,17 +117,36 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
109117
return@get
110118
}
111119

120+
// IMPORTANT Do not let `expandTo` be an arbitrary string to avoid code injection.
121+
// The value of `expandTo` is expanded into JavaScript.
122+
val expandTo = call.request.queryParameters["expandTo"]?.let {
123+
it.toLongOrNull() ?: return@get call.respondText("Invalid expandTo value. Provide a node id.", status = HttpStatusCode.BadRequest)
124+
}
125+
112126
repoManager.runWithRepository(repositoryId) {
113127
val tree = CLVersion.loadFromHash(versionHash, client.storeCache).getTree()
114128
val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree))
115129

130+
val expandedNodes = expandTo?.let { nodeId -> getAncestorsAndSelf(nodeId, tree) }.orEmpty()
131+
116132
call.respondHtmlTemplate(PageWithMenuBar("repos/", "../../../../..")) {
117133
headContent {
118134
title("Content Explorer")
119135
link("../../../../../public/content-explorer.css", rel = "stylesheet")
120136
script("text/javascript", src = "../../../../../public/content-explorer.js") {}
137+
if (expandTo != null) {
138+
script("text/javascript") {
139+
unsafe {
140+
+"""
141+
document.addEventListener("DOMContentLoaded", function(event) {
142+
scrollToElement('$expandTo');
143+
});
144+
""".trimIndent()
145+
}
146+
}
147+
}
121148
}
122-
bodyContent { contentPageBody(rootNode, versionHash, emptySet()) }
149+
bodyContent { contentPageBody(rootNode, versionHash, expandedNodes, expandTo) }
123150
}
124151
}
125152
}
@@ -188,6 +215,17 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
188215
}
189216
}
190217

218+
private fun getAncestorsAndSelf(expandTo: Long, tree: CLTree): Set<String> {
219+
val seq = generateSequence(expandTo) { id ->
220+
try {
221+
tree.resolveElement(id)?.parentId
222+
} catch (e: org.modelix.model.lazy.NodeNotFoundException) {
223+
throw NodeNotFoundException(id, e)
224+
}
225+
}
226+
return seq.map { it.toString() }.toSet()
227+
}
228+
191229
// The method traverses the expanded tree based on the alreadyExpandedNodeIds and
192230
// collects the expandable (not empty) nodes which are not expanded yet
193231
private fun collectExpandableChildNodes(under: PNodeAdapter, alreadyExpandedNodeIds: Set<String>): Set<String> {
@@ -206,7 +244,12 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
206244
return emptySet()
207245
}
208246

209-
private fun FlowContent.contentPageBody(rootNode: PNodeAdapter, versionHash: String, expandedNodeIds: Set<String>) {
247+
private fun FlowContent.contentPageBody(
248+
rootNode: PNodeAdapter,
249+
versionHash: String,
250+
expandedNodeIds: Set<String>,
251+
expandTo: Long?,
252+
) {
210253
h1 { +"Model Server Content" }
211254
small {
212255
style = "color: #888; text-align: center; margin-bottom: 15px;"
@@ -223,24 +266,42 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
223266
+"Collapse all"
224267
}
225268
}
269+
getForm(action = ".") {
270+
label {
271+
htmlFor = "expandTo"
272+
+"Expand to Node: "
273+
}
274+
textInput {
275+
name = "expandTo"
276+
placeholder = "nodeId"
277+
required = true
278+
}
279+
submitInput(classes = "btn") {
280+
value = "Go"
281+
}
282+
}
226283
div {
227284
id = "treeWrapper"
228285
ul("treeRoot") {
229-
nodeItem(rootNode, expandedNodeIds)
286+
nodeItem(rootNode, expandedNodeIds, expandTo)
230287
}
231288
}
232289
div {
233290
id = "nodeInspector"
234291
}
235292
}
236293

237-
private fun UL.nodeItem(node: PNodeAdapter, expandedNodeIds: Set<String>) {
294+
private fun UL.nodeItem(node: PNodeAdapter, expandedNodeIds: Set<String>, expandTo: Long? = null) {
238295
li("nodeItem") {
296+
id = node.nodeId.toString()
239297
val expanded = expandedNodeIds.contains(node.nodeId.toString())
240298
if (node.allChildren.toList().isNotEmpty()) {
241299
div(if (expanded) "expander expander-expanded" else "expander") { unsafe { +"&#x25B6;" } }
242300
}
243301
div("nameField") {
302+
if (expandTo == node.nodeId) {
303+
classes += "expandedToNameField"
304+
}
244305
attributes["data-nodeid"] = node.nodeId.toString()
245306
b {
246307
val namePropertyUID = BuiltinLanguages.jetbrains_mps_lang_core.INamedConcept.name.getUID()
@@ -268,7 +329,7 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
268329
if (expanded) {
269330
ul("nodeTree") {
270331
for (child in node.allChildren) {
271-
nodeItem(child as PNodeAdapter, expandedNodeIds)
332+
nodeItem(child as PNodeAdapter, expandedNodeIds, expandTo)
272333
}
273334
}
274335
}
@@ -322,7 +383,14 @@ class ContentExplorer(private val client: IModelClient, private val repoManager:
322383
tr {
323384
td { +referenceRole }
324385
td {
325-
+"${(node.getReferenceTarget(referenceRole) as? PNodeAdapter)?.nodeId}"
386+
val nodeId = (node.getReferenceTarget(referenceRole) as? PNodeAdapter)?.nodeId
387+
if (nodeId != null) {
388+
a("?expandTo=$nodeId") {
389+
+"$nodeId"
390+
}
391+
} else {
392+
+"null"
393+
}
326394
}
327395
td {
328396
+"${node.getReferenceTargetRef(referenceRole)?.serialize()}"

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
.selectedNameField {
4646
background-color: #c9c9c9;
4747
}
48+
.expandedToNameField {
49+
border: 2px solid #32cd32;
50+
background-color: #90ee90
51+
}
4852
#treeWrapper {
4953
align-self: flex-start;
5054
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ function getExpandedNodeIds() {
1111
element => element.nextElementSibling?.getAttribute('data-nodeid'));
1212
}
1313

14+
function scrollToElement(id) {
15+
document.getElementById(id).scrollIntoView();
16+
}
17+
1418
function sendExpandNodeRequest(expandAll) {
1519
const xhr = new XMLHttpRequest();
1620
xhr.onreadystatechange = () => {
@@ -40,6 +44,7 @@ function addContentExplorerClickListeners() {
4044
selected[j].classList.remove('selectedNameField');
4145
}
4246
if (!isSelected) {
47+
this.classList.remove('expandedToNameField');
4348
this.classList.add('selectedNameField');
4449
}
4550
});

model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/ContentExplorerTest.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616

1717
package org.modelix.model.server.handlers.ui
1818

19+
import io.kotest.assertions.ktor.client.shouldHaveStatus
20+
import io.kotest.matchers.string.shouldContain
1921
import io.ktor.client.call.body
2022
import io.ktor.client.request.get
2123
import io.ktor.client.request.post
2224
import io.ktor.client.request.setBody
2325
import io.ktor.client.statement.bodyAsText
2426
import io.ktor.http.ContentType
27+
import io.ktor.http.HttpStatusCode
2528
import io.ktor.http.contentType
2629
import io.ktor.serialization.kotlinx.json.json
2730
import io.ktor.server.testing.ApplicationTestBuilder
@@ -138,4 +141,29 @@ class ContentExplorerTest {
138141
assertTrue { root.`is`(Evaluator.Class("treeRoot")) }
139142
assertTrue { root.childrenSize() > 0 }
140143
}
144+
145+
@Test
146+
fun `expanding to non-existing node leads to not found response`() = runTest {
147+
val client = createHttpClient()
148+
val nodeId = "654321"
149+
150+
val delta: VersionDelta = client.post("/v2/repositories/node-expand-to/init").body()
151+
val versionHash = delta.versionHash
152+
val response = client.get("/content/repositories/node-expand-to/versions/$versionHash/?expandTo=$nodeId")
153+
154+
response shouldHaveStatus HttpStatusCode.NotFound
155+
response.bodyAsText() shouldContain nodeId
156+
}
157+
158+
@Test
159+
fun `illegal expandTo value leads to bad request response`() = runTest {
160+
val client = createHttpClient()
161+
val nodeId = "illegalId"
162+
163+
val delta: VersionDelta = client.post("/v2/repositories/node-expand-to/init").body()
164+
val versionHash = delta.versionHash
165+
val response = client.get("/content/repositories/node-expand-to/versions/$versionHash/?expandTo=$nodeId")
166+
167+
response shouldHaveStatus HttpStatusCode.BadRequest
168+
}
141169
}

0 commit comments

Comments
 (0)