Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,20 @@ data class ContainmentChanged(override val node: INodeJS) : ChangeJS
*/
@JsExport
data class ConceptChanged(override val node: INodeJS) : ChangeJS

/**
* Represents an added [node].
* Every node of an added subtree is reported, not only the root of the subtree.
* Only reported to handlers added with [MutableModelTreeJs.addListenerIncludingAddedAndRemovedNodes].
*/
@JsExport
data class NodeAdded(override val node: INodeJS) : ChangeJS

/**
* Represents a removed [node].
* Every node of a removed subtree is reported, not only the root of the subtree.
* Only reported to handlers added with [MutableModelTreeJs.addListenerIncludingAddedAndRemovedNodes].
* Only the reference of [node] can be read, because it is no longer part of the model.
*/
@JsExport
data class NodeRemoved(override val node: INodeJS) : ChangeJS
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,22 @@ interface MutableModelTreeJs {
*/
fun addListener(handler: ChangeHandler)

/**
* Like [addListener], but the change handler is also called with [NodeAdded] and [NodeRemoved]
* for every node of an added or removed subtree.
* The order of these changes within one change of the branch,
* also relative to the other changes, is not specified.
* The handler is called more often than with [addListener],
* and the changes of the branch are computed once more for each such handler,
* so use it only when added or removed nodes have to be known.
* Once a handler is added with this method, it gets these changes until it is removed with [removeListener],
* even if it is also added with [addListener].
*/
fun addListenerIncludingAddedAndRemovedNodes(handler: ChangeHandler)

/**
* Remove a change handler from the branch.
* It removes handlers added with [addListener] and with [addListenerIncludingAddedAndRemovedNodes].
*/
fun removeListener(handler: ChangeHandler)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ internal class MutableModelTreeJsImpl(

private val changeHandlers = mutableSetOf<ChangeHandler>()

// The listeners that report [NodeAdded] and [NodeRemoved] to the handlers added with
// [addListenerIncludingAddedAndRemovedNodes], one per tree.
// They are only created and registered for these handlers, so that other handlers cost nothing extra.
private var addedAndRemovedNodesListeners: MutableMap<ChangeHandler, List<Pair<IMutableModelTree, AddedAndRemovedNodesListener>>>? = null

private val model = CompositeModel(trees.map { it.asModel() }).withAutoTransactions()
private val changeListeners = trees.map { tree ->
ChangeListener(tree.withAutoTransactions()) { change ->
Expand All @@ -51,8 +56,18 @@ internal class MutableModelTreeJsImpl(
override fun addListener(handler: ChangeHandler) {
changeHandlers.add(handler)
}
override fun addListenerIncludingAddedAndRemovedNodes(handler: ChangeHandler) {
changeHandlers.add(handler)
val listeners = addedAndRemovedNodesListeners ?: mutableMapOf<ChangeHandler, List<Pair<IMutableModelTree, AddedAndRemovedNodesListener>>>()
.also { addedAndRemovedNodesListeners = it }
if (handler in listeners) return
listeners[handler] = trees.map { tree ->
tree to AddedAndRemovedNodesListener(tree.withAutoTransactions(), handler).also { tree.addListener(it) }
}
}
override fun removeListener(handler: ChangeHandler) {
changeHandlers.remove(handler)
addedAndRemovedNodesListeners?.remove(handler)?.forEach { (tree, listener) -> tree.removeListener(listener) }
}

private fun IWritableNode.toJS() = toNodeJs(this.withAutoTransactions().asLegacyNode())
Expand All @@ -79,3 +94,25 @@ internal class ChangeListener(private val tree: IMutableModelTree, private val c
}
}
}

/**
* Reports only [NodeAdded] and [NodeRemoved] to [changeCallback].
* The other changes are reported by [ChangeListener].
*/
internal class AddedAndRemovedNodesListener(private val tree: IMutableModelTree, private val changeCallback: (ChangeJS) -> Unit) :
IGenericMutableModelTree.Listener<INodeReference> {

private fun nodeIdToInode(nodeId: INodeReference): INodeJS {
return toNodeJs(NodeInMutableModel(tree, nodeId).asLegacyNode())
}

override fun treeChanged(oldTree: IGenericModelTree<INodeReference>, newTree: IGenericModelTree<INodeReference>) {
newTree.getChanges(oldTree, false).iterateBlocking(newTree) {
when (it) {
is NodeAddedEvent<INodeReference> -> changeCallback(NodeAdded(nodeIdToInode(it.nodeId)))
is NodeRemovedEvent<INodeReference> -> changeCallback(NodeRemoved(nodeIdToInode(it.nodeId)))
else -> {}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
package org.modelix.model.client2

import GeneratedConcept
import org.modelix.datastructures.model.IGenericModelTree
import org.modelix.model.api.INodeReference
import org.modelix.model.client.IdGenerator
import org.modelix.model.data.ModelData
import org.modelix.model.lazy.createObjectStoreCache
import org.modelix.model.mutable.IGenericMutableModelTree
import org.modelix.model.mutable.IMutableModelTree
import org.modelix.model.mutable.ModelixIdGenerator
import org.modelix.model.mutable.asMutableThreadSafe
import org.modelix.model.mutable.load
import org.modelix.model.persistent.MapBasedStore
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue

class MutableModelTreeJsTest {

Expand Down Expand Up @@ -201,4 +213,202 @@ class MutableModelTreeJsTest {
// Assert
assertEquals(1, childrenChanged)
}

@Test
fun changeHandlerIncludingAddedAndRemovedNodesGetsEveryNodeOfARemovedSubtree() {
// Arrange
val data = """
{
"root": {
"children": [
{
"id": "subtreeRoot",
"children": [
{
"id": "descendant"
}
]
}
]
}
}
""".trimIndent()
val branch = loadModelsFromJsonAsBranch(arrayOf(data))
val subtreeRoot = branch.rootNode.getAllChildren()[0]
val descendant = subtreeRoot.getAllChildren()[0]
var childrenChanged = 0
val removedNodeReferences = mutableListOf<Any?>()
branch.addListenerIncludingAddedAndRemovedNodes {
when (it) {
is ChildrenChanged -> childrenChanged++
// The reference of a removed node can still be read.
is NodeRemoved -> removedNodeReferences.add(it.node.getReference())
else -> {}
}
}
var changeCount = 0
branch.addListener { changeCount++ }

// Act
branch.rootNode.removeChild(subtreeRoot)

// Assert
assertEquals(1, childrenChanged)
assertEquals(
setOf<Any?>(subtreeRoot.getReference(), descendant.getReference()),
removedNodeReferences.toSet(),
)
assertEquals(2, removedNodeReferences.size)
// A handler added with `addListener` still gets only the change to the children.
assertEquals(1, changeCount)
}

@Test
fun changeHandlerIncludingAddedAndRemovedNodesGetsAnAddedNode() {
// Arrange
val branch = loadModelsFromJsonAsBranch(arrayOf(emptyRoot))
val addedNodeReferences = mutableListOf<Any?>()
val changeHandler: ChangeHandler = {
if (it is NodeAdded) {
addedNodeReferences.add(it.node.getReference())
}
}
branch.addListenerIncludingAddedAndRemovedNodes(changeHandler)
// Adding the handler again with `addListener` does not opt it out.
branch.addListener(changeHandler)

// Act
val childNode = branch.rootNode.addNewChild("aRole", -1, GeneratedConcept("aConceptUid"))

// Assert
assertEquals(listOf<Any?>(childNode.getReference()), addedNodeReferences)
}

@Test
fun changeHandlersAreCalledInTheOrderTheyWereAdded() {
// Arrange
val branch = loadModelsFromJsonAsBranch(arrayOf(emptyRoot))
val calledHandlers = mutableListOf<String>()
branch.addListenerIncludingAddedAndRemovedNodes { calledHandlers.add("includingAddedAndRemovedNodes") }
branch.addListener { calledHandlers.add("plain") }

// Act
branch.rootNode.setPropertyValue("aProperty", "aValue")

// Assert
assertEquals(listOf("includingAddedAndRemovedNodes", "plain"), calledHandlers)
}

@Test
fun changeHandlerIncludingAddedAndRemovedNodesCanBeRemoved() {
val branch = loadModelsFromJsonAsBranch(arrayOf(rootWithChild))
var changeCount = 0
val changeListener: ChangeHandler = { _ -> changeCount++ }
branch.addListenerIncludingAddedAndRemovedNodes(changeListener)
branch.removeListener(changeListener)

val aNode = branch.rootNode.getAllChildren()[0]
branch.rootNode.removeChild(aNode)

assertEquals(0, changeCount)
}

@Test
fun changeHandlerIncludingAddedAndRemovedNodesGetsEveryNodeOfAnAddedSubtree() {
// Arrange
val tree = ListenerTrackingTree(createEmptyTree())
val branch = MutableModelTreeJsImpl(tree)
var childrenChanged = 0
val addedNodeReferences = mutableListOf<Any?>()
branch.addListenerIncludingAddedAndRemovedNodes {
when (it) {
is ChildrenChanged -> childrenChanged++
is NodeAdded -> addedNodeReferences.add(it.node.getReference())
else -> {}
}
}
val data = """
{
"root": {
"children": [
{
"id": "subtreeRoot",
"children": [
{
"id": "descendant"
}
]
}
]
}
}
""".trimIndent()

// Act
ModelData.fromJson(data).load(tree)

// Assert
val subtreeRoot = branch.rootNode.getAllChildren()[0]
val descendant = subtreeRoot.getAllChildren()[0]
assertEquals(
setOf<Any?>(subtreeRoot.getReference(), descendant.getReference()),
addedNodeReferences.toSet(),
)
assertEquals(2, addedNodeReferences.size)
assertEquals(1, childrenChanged)
}

@Test
fun addedAndRemovedNodesAreOnlyReportedWhileAHandlerIncludingThemExists() {
// Arrange
val tree = ListenerTrackingTree(createEmptyTree())
val branch = MutableModelTreeJsImpl(tree)
val changes = mutableListOf<ChangeJS>()
branch.addListener { changes.add(it) }
val handlerIncludingAddedAndRemovedNodes: ChangeHandler = {}

// Act
val childNode = branch.rootNode.addNewChild("aRole", -1, GeneratedConcept("aConceptUid"))
branch.rootNode.removeChild(childNode)

// Assert
// Only the listener for the other changes is registered, so added and removed nodes are not even looked up.
assertEquals(1, tree.listeners.size)
assertEquals(2, changes.size)
assertTrue(changes.none { it is NodeAdded || it is NodeRemoved })

// Act
branch.addListenerIncludingAddedAndRemovedNodes(handlerIncludingAddedAndRemovedNodes)

// Assert
assertEquals(2, tree.listeners.size)

// Act
branch.removeListener(handlerIncludingAddedAndRemovedNodes)

// Assert
assertEquals(1, tree.listeners.size)
}

private fun createEmptyTree(): IMutableModelTree {
val tree = IGenericModelTree.builder()
.graph(createObjectStoreCache(MapBasedStore()).asObjectGraph())
.withNodeReferenceIds()
.build()
return tree.asMutableThreadSafe(ModelixIdGenerator(IdGenerator.getInstance(1), tree.getId()))
}

private class ListenerTrackingTree(private val tree: IMutableModelTree) : IMutableModelTree by tree {
val listeners = mutableListOf<IGenericMutableModelTree.Listener<INodeReference>>()

override fun addListener(listener: IGenericMutableModelTree.Listener<INodeReference>) {
listeners.add(listener)
tree.addListener(listener)
}

override fun removeListener(listener: IGenericMutableModelTree.Listener<INodeReference>) {
listeners.remove(listener)
tree.removeListener(listener)
}
}
}
15 changes: 15 additions & 0 deletions vue-model-api/src/ReactiveModelOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Options for the reactive nodes returned by `useModelsFromJson`, `useReplicatedModel`
* and `useReplicatedModels`.
*/
export interface ReactiveModelOptions {
/**
* Make `getReferenceTargetNode` read a reference to a removed node as unset.
* `getReferenceTargetRef` still returns the reference.
* Without it, a view that shows such a reference keeps showing the removed node and does not update.
* With it, every node of every added or removed subtree is looked up,
* for example, for a large merge from the server,
* so adding or removing many nodes at once costs more. Default: `false`.
*/
updateReferencesToRemovedNodes?: boolean;
}
1 change: 1 addition & 0 deletions vue-model-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { useModelClient } from "./useModelClient";
export { useReplicatedModels } from "./useReplicatedModels";
export { useReplicatedModel } from "./useReplicatedModel";
export { useReadonlyVersion } from "./useReadonlyVersion";
export type { ReactiveModelOptions } from "./ReactiveModelOptions";
Loading
Loading