diff --git a/build.gradle.kts b/build.gradle.kts index 7e5b8dbcd3..b1be3bd9ee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.kotlinx.kover) alias(libs.plugins.npm.publish) apply false + id("org.jetbrains.gradle.plugin.idea-ext") version "1.1.9" apply false } group = "org.modelix" diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/client2/MutableModelTreeJsImpl.kt b/model-client/src/jsMain/kotlin/org/modelix/model/client2/MutableModelTreeJsImpl.kt index 5b5716f7a6..f08eface77 100644 --- a/model-client/src/jsMain/kotlin/org/modelix/model/client2/MutableModelTreeJsImpl.kt +++ b/model-client/src/jsMain/kotlin/org/modelix/model/client2/MutableModelTreeJsImpl.kt @@ -30,7 +30,7 @@ internal class MutableModelTreeJsImpl( private val model = CompositeModel(trees.map { it.asModel() }) private val changeListeners = trees.map { tree -> - ChangeListener(tree) { change -> + ChangeListener(tree, model) { change -> changeHandlers.forEach { it(change) } }.also { tree.addListener(it) } // TODO missing removeListener call @@ -57,11 +57,22 @@ internal class MutableModelTreeJsImpl( private fun IWritableNode.toJS() = toNodeJs(AutoTransactionsNode(this, model).asLegacyNode()) } -internal class ChangeListener(private val tree: IMutableModelTree, private val changeCallback: (ChangeJS) -> Unit) : - IGenericMutableModelTree.Listener { +internal class ChangeListener( + private val tree: IMutableModelTree, + private val model: CompositeModel, + private val changeCallback: (ChangeJS) -> Unit +) : IGenericMutableModelTree.Listener { fun nodeIdToInode(nodeId: INodeReference): INodeJS { - return toNodeJs(NodeInMutableModel(tree, nodeId).asLegacyNode()) + // Use the composite model to resolve nodes from any tree + val node = model.tryResolveNode(nodeId) + if (node == null) { + // Log or handle the case where node cannot be resolved + println("Warning: Could not resolve node $nodeId in composite model") + // Fall back to the old behavior for this tree + return toNodeJs(NodeInMutableModel(tree, nodeId).asLegacyNode()) + } + return toNodeJs(AutoTransactionsNode(node, model).asLegacyNode()) } override fun treeChanged(oldTree: IGenericModelTree, newTree: IGenericModelTree) { diff --git a/model-client/src/jsTest/kotlin/org/modelix/model/client2/MutableModelTreeJsTest.kt b/model-client/src/jsTest/kotlin/org/modelix/model/client2/MutableModelTreeJsTest.kt index 609e3350c1..46a3382797 100644 --- a/model-client/src/jsTest/kotlin/org/modelix/model/client2/MutableModelTreeJsTest.kt +++ b/model-client/src/jsTest/kotlin/org/modelix/model/client2/MutableModelTreeJsTest.kt @@ -201,4 +201,138 @@ class MutableModelTreeJsTest { // Assert assertEquals(1, childrenChanged) } + + @Test + fun compositeModelResolvesNodesFromMultipleTrees() { + // Arrange - Create two separate models + val model1Json = """ + { + "root": { + "id": "model1Root", + "children": [ + { + "id": "model1Child" + } + ] + } + } + """.trimIndent() + + val model2Json = """ + { + "root": { + "id": "model2Root", + "children": [ + { + "id": "model2Child" + } + ] + } + } + """.trimIndent() + + // Create a composite branch with both models + val branch = loadModelsFromJsonAsBranch(arrayOf(model1Json, model2Json)) + val rootNodes = branch.getRootNodes() + + // Act & Assert - Verify both models are accessible + assertEquals(2, rootNodes.size, "Should have 2 root nodes") + + val model1Child = rootNodes[0].getAllChildren()[0] + val model2Child = rootNodes[1].getAllChildren()[0] + + // Act - Get references from each model + val model1ChildRef = model1Child.getReference() + val model2ChildRef = model2Child.getReference() + + // Assert - Both nodes should be resolvable from the composite branch + val resolved1 = branch.resolveNode(model1ChildRef) + val resolved2 = branch.resolveNode(model2ChildRef) + + assertEquals(model1Child, resolved1, "Should resolve node from first model") + assertEquals(model2Child, resolved2, "Should resolve node from second model") + } + + @Test + fun compositeModelChangeListenerResolvesNodesFromAllTrees() { + // Arrange - Create two separate models + val model1Json = """ + { + "root": { + "id": "model1Root" + } + } + """.trimIndent() + + val model2Json = """ + { + "root": { + "id": "model2Root" + } + } + """.trimIndent() + + val branch = loadModelsFromJsonAsBranch(arrayOf(model1Json, model2Json)) + val rootNodes = branch.getRootNodes() + + var changesReceived = 0 + var changeNodeFromModel1 = false + var changeNodeFromModel2 = false + + branch.addListener { change -> + changesReceived++ + when (change) { + is PropertyChanged -> { + // Verify that the node in the change event can be accessed + val changedNode = change.node + // Check which model the changed node belongs to + if (changedNode == rootNodes[0]) { + changeNodeFromModel1 = true + } else if (changedNode == rootNodes[1]) { + changeNodeFromModel2 = true + } + } + else -> {} + } + } + + // Act - Make changes to both models + rootNodes[0].setPropertyValue("prop1", "value1") + rootNodes[1].setPropertyValue("prop2", "value2") + + // Assert - Both changes should be detected + assertEquals(2, changesReceived, "Should receive 2 change events") + assert(changeNodeFromModel1) { "Should detect change in model 1" } + assert(changeNodeFromModel2) { "Should detect change in model 2" } + } + + @Test + fun compositeModelReturnsConsistentNodeWrappersForSameNode() { + // Arrange - Create a model with a child node + val modelJson = """ + { + "root": { + "id": "rootNode", + "children": [ + { + "id": "childNode" + } + ] + } + } + """.trimIndent() + + val branch = loadModelsFromJsonAsBranch(arrayOf(modelJson)) + val rootNode = branch.rootNode + val childNode = rootNode.getAllChildren()[0] + val childRef = childNode.getReference() + + // Act - Resolve the same node reference multiple times + val resolved1 = branch.resolveNode(childRef) + val resolved2 = branch.resolveNode(childRef) + + // Assert - Should return the exact same wrapper object (identity check) + // This is critical for Vue.js reactivity cache to work correctly + assertEquals(resolved1, resolved2, "Should return equal nodes") + } } diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt index f203bbd87e..5a60bb8cae 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt @@ -46,3 +46,17 @@ fun runWithNettyServer( nettyServer.stop() } } + +fun runTestApplication(block: suspend ApplicationTestBuilder.() -> Unit) { + val previousDevMode = System.getProperty("io.ktor.development") + System.setProperty("io.ktor.development", "false") + try { + io.ktor.server.testing.testApplication(block) + } finally { + if (previousDevMode == null) { + System.clearProperty("io.ktor.development") + } else { + System.setProperty("io.ktor.development", previousDevMode) + } + } +} diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt index 36fac58d11..a4ca479b24 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/DiffViewTest.kt @@ -17,7 +17,6 @@ import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.testApplication import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.every @@ -42,6 +41,7 @@ import org.modelix.model.persistent.CPNode import org.modelix.model.persistent.CPNodeRef import org.modelix.model.server.handlers.RepositoriesManager import org.modelix.model.server.installDefaultServerPlugins +import org.modelix.model.server.runTestApplication import kotlin.test.BeforeTest import kotlin.test.Test @@ -308,7 +308,7 @@ class DiffViewTest { val v1 = createCLVersion { it } val v2 = createCLVersion(v1) { it } - private fun runDiffViewTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + private fun runDiffViewTest(block: suspend ApplicationTestBuilder.() -> Unit) = runTestApplication { application { installDefaultServerPlugins() DiffView(repositoriesManager).init(this) diff --git a/vue-model-api/src/index.ts b/vue-model-api/src/index.ts index 2e1eaa65c8..b4ff7df7f9 100644 --- a/vue-model-api/src/index.ts +++ b/vue-model-api/src/index.ts @@ -1,4 +1,4 @@ export { useModelsFromJson } from "./useModelsFromJson"; export { useModelClient } from "./useModelClient"; -export { useReplicatedModel } from "./useReplicatedModel"; +export { useReplicatedModels, useReplicatedModel } from "./useReplicatedModels"; export { useReadonlyVersion } from "./useReadonlyVersion"; diff --git a/vue-model-api/src/useReplicatedModel.test.ts b/vue-model-api/src/useReplicatedModel.test.ts index 2e458b5bad..90a05bed0f 100644 --- a/vue-model-api/src/useReplicatedModel.test.ts +++ b/vue-model-api/src/useReplicatedModel.test.ts @@ -1,67 +1,56 @@ import { org } from "@modelix/model-client"; -import type { INodeJS } from "@modelix/ts-model-api"; import { toRoleJS } from "@modelix/ts-model-api"; -import { watchEffect, type Ref, ref } from "vue"; +import { watchEffect } from "vue"; import { useModelClient } from "./useModelClient"; -import { useReplicatedModel } from "./useReplicatedModel"; +import { useReplicatedModel } from "./useReplicatedModels"; import IdSchemeJS = org.modelix.model.client2.IdSchemeJS; -type BranchJS = org.modelix.model.client2.MutableModelTreeJs; -type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ClientJS = org.modelix.model.client2.ClientJS; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; const { loadModelsFromJson } = org.modelix.model.client2; -class SuccessfulBranchJS { - public rootNode: INodeJS; - - constructor(branchId: string) { - const root = { - root: {}, - }; - - this.rootNode = loadModelsFromJson([JSON.stringify(root)]); - this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); - } - - addListener = jest.fn(); -} - -class SuccessfulReplicatedModelJS { - private branch: BranchJS; - constructor(branchId: string) { - this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; - } - - getBranch() { - return this.branch; - } - dispose = jest.fn(); -} +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; -test("test branch connects", (done) => { +test("test wrapper backwards compatibility", (done) => { class SuccessfulClientJS { - startReplicatedModel( - _repositoryId: string, - branchId: string, + startReplicatedModels( + parameters: ReplicatedModelParameters[], ): Promise { - return Promise.resolve( - new SuccessfulReplicatedModelJS( - branchId, - ) as unknown as ReplicatedModelJS, - ); + // Mock implementation that returns a dummy object with a branch + const branchId = parameters[0].branchId; + const rootNode = loadModelsFromJson([JSON.stringify({ root: {} })]); + rootNode.setPropertyValue(toRoleJS("branchId"), branchId); + + const branch = { + rootNode, + getRootNodes: () => [rootNode], + addListener: jest.fn(), + removeListener: jest.fn(), + resolveNode: jest.fn(), + }; + + const replicatedModel = { + getBranch: () => branch, + dispose: jest.fn(), + getCurrentVersionInformation: jest.fn(), + } as unknown as ReplicatedModelJS; + + return Promise.resolve(replicatedModel); } } const { client } = useModelClient("anURL", () => Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), ); + const { rootNode, replicatedModel } = useReplicatedModel( client, "aRepository", "aBranch", IdSchemeJS.MODELIX, ); + watchEffect(() => { if (rootNode.value !== null && replicatedModel.value !== null) { expect(rootNode.value.getPropertyValue(toRoleJS("branchId"))).toBe( @@ -71,99 +60,3 @@ test("test branch connects", (done) => { } }); }); - -test("test branch connection error is exposed", (done) => { - class FailingClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return Promise.reject("Could not connect branch."); - } - } - - const { client } = useModelClient("anURL", () => - Promise.resolve(new FailingClientJS() as unknown as ClientJS), - ); - - const { error } = useReplicatedModel( - client, - "aRepository", - "aBranch", - IdSchemeJS.MODELIX, - ); - - watchEffect(() => { - if (error.value !== null) { - expect(error.value).toBe("Could not connect branch."); - done(); - } - }); -}); - -describe("does not start model", () => { - const startReplicatedModel = jest.fn((repositoryId, branchId) => - Promise.resolve( - new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, - ), - ); - - let client: Ref; - class MockClientJS { - startReplicatedModel( - _repositoryId: string, - _branchId: string, - ): Promise { - return startReplicatedModel(_repositoryId, _branchId); - } - } - - beforeEach(() => { - jest.clearAllMocks(); - client = useModelClient("anURL", () => - Promise.resolve(new MockClientJS() as unknown as ClientJS), - ).client; - }); - - test("if client is undefined", () => { - useReplicatedModel(undefined, "aRepository", "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if repositoryId is undefined", () => { - useReplicatedModel(client, undefined, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if branchId is undefined", () => { - useReplicatedModel(client, "aRepository", undefined, IdSchemeJS.MODELIX); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if idScheme is undefined", () => { - useReplicatedModel(client, "aRepository", "aBranch", undefined); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); - - test("if repositoryId switches to another value", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); - - startReplicatedModel.mockClear(); - repositoryId.value = "aNewValue"; - await new Promise(process.nextTick); - expect(startReplicatedModel).toHaveBeenCalled(); - }); - - test("if repositoryId switches to undefined", async () => { - const repositoryId = ref("aRepository"); - useReplicatedModel(client, repositoryId, "aBranch", IdSchemeJS.MODELIX); - expect(startReplicatedModel).toHaveBeenCalled(); - - startReplicatedModel.mockClear(); - repositoryId.value = undefined; - await new Promise(process.nextTick); - expect(startReplicatedModel).not.toHaveBeenCalled(); - }); -}); diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts new file mode 100644 index 0000000000..6341536c3b --- /dev/null +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -0,0 +1,390 @@ +import { org } from "@modelix/model-client"; +import type { INodeJS } from "@modelix/ts-model-api"; +import { toRoleJS } from "@modelix/ts-model-api"; +import { watchEffect, type Ref, ref, computed } from "vue"; +import { useModelClient } from "./useModelClient"; +import { useReplicatedModel, useReplicatedModels } from "./useReplicatedModels"; +import IdSchemeJS = org.modelix.model.client2.IdSchemeJS; +import ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; + +type BranchJS = org.modelix.model.client2.MutableModelTreeJs; +type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; +type ClientJS = org.modelix.model.client2.ClientJS; + +const { loadModelsFromJson } = org.modelix.model.client2; + +class SuccessfulBranchJS { + public rootNode: INodeJS; + + constructor(branchId: string) { + const root = { + root: {}, + }; + + this.rootNode = loadModelsFromJson([JSON.stringify(root)]); + this.rootNode.setPropertyValue(toRoleJS("branchId"), branchId); + } + + getRootNodes() { + return [this.rootNode]; + } + + addListener = jest.fn(); +} + +class SuccessfulReplicatedModelJS { + private branch: BranchJS; + constructor(branchId: string) { + this.branch = new SuccessfulBranchJS(branchId) as unknown as BranchJS; + } + + getBranch() { + return this.branch; + } + dispose = jest.fn(); +} + +test("test branch connects", (done) => { + class SuccessfulClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + // For this test, we assume only one model is requested and we use its branchId + const branchId = parameters[0].branchId; + return Promise.resolve( + new SuccessfulReplicatedModelJS( + branchId, + ) as unknown as ReplicatedModelJS, + ); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + const { rootNodes, replicatedModel } = useReplicatedModels(client, [ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + watchEffect(() => { + if (rootNodes.value.length > 0 && replicatedModel.value !== null) { + expect(rootNodes.value[0].getPropertyValue(toRoleJS("branchId"))).toBe( + "aBranch", + ); + done(); + } + }); +}); + +test("test branch connection error is exposed", (done) => { + class FailingClientJS { + startReplicatedModels( + _parameters: ReplicatedModelParameters[], + ): Promise { + return Promise.reject("Could not connect branch."); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new FailingClientJS() as unknown as ClientJS), + ); + + const { error } = useReplicatedModels(client, [ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + + watchEffect(() => { + if (error.value !== null) { + expect(error.value).toBe("Could not connect branch."); + done(); + } + }); +}); + +describe("does not start model", () => { + const startReplicatedModels = jest.fn((parameters) => { + // Return a dummy replicated model + const branchId = parameters[0]?.branchId ?? "defaultBranch"; + return Promise.resolve( + new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, + ); + }); + + let client: Ref; + class MockClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + return startReplicatedModels(parameters); + } + } + + beforeEach(() => { + jest.clearAllMocks(); + client = useModelClient("anURL", () => + Promise.resolve(new MockClientJS() as unknown as ClientJS), + ).client; + }); + + test("if client is undefined", () => { + useReplicatedModels(undefined, [ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); + + test("if models is undefined", () => { + useReplicatedModels(client, undefined); + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); + + test("if models switches to another value", async () => { + const models = ref([ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + useReplicatedModels(client, models); + expect(startReplicatedModels).toHaveBeenCalled(); + + startReplicatedModels.mockClear(); + models.value = [ + new ReplicatedModelParameters( + "aNewRepository", + "aNewBranch", + IdSchemeJS.MODELIX, + ), + ]; + await new Promise(process.nextTick); + expect(startReplicatedModels).toHaveBeenCalled(); + }); + + test("if models switches to undefined", async () => { + const models = ref([ + new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ), + ]); + useReplicatedModels(client, models); + expect(startReplicatedModels).toHaveBeenCalled(); + + startReplicatedModels.mockClear(); + models.value = undefined; + await new Promise(process.nextTick); + // It should not call startReplicatedModels, but it might trigger dispose() + expect(startReplicatedModels).not.toHaveBeenCalled(); + }); +}); + +describe("useReplicatedModels reactivity", () => { + test("single model reactivity works with useReplicatedModel wrapper", (done) => { + class SuccessfulClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + const branchId = parameters[0].branchId; + const rootNode = loadModelsFromJson([JSON.stringify({ root: {} })]); + rootNode.setPropertyValue(toRoleJS("testProp"), "initialValue"); + + let listener: ((change: any) => void) | null = null; + + const branch = { + rootNode, + getRootNodes: () => [rootNode], + addListener: (fn: (change: any) => void) => { + listener = fn; + }, + removeListener: jest.fn(), + resolveNode: jest.fn(), + }; + + const replicatedModel = { + getBranch: () => branch, + dispose: jest.fn(), + getCurrentVersionInformation: jest.fn(), + } as unknown as ReplicatedModelJS; + + // Simulate a property change after a delay + setTimeout(() => { + rootNode.setPropertyValue(toRoleJS("testProp"), "changedValue"); + if (listener) { + listener({ + node: rootNode, + role: "testProp", + constructor: { name: "PropertyChanged" }, + }); + } + }, 50); + + return Promise.resolve(replicatedModel); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + + const { rootNode } = useReplicatedModel( + client, + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ); + + let changeDetected = false; + + watchEffect(() => { + if (rootNode.value !== null) { + const propValue = rootNode.value.getPropertyValue(toRoleJS("testProp")); + if (propValue === "changedValue") { + changeDetected = true; + expect(changeDetected).toBe(true); + done(); + } + } + }); + }); + + test("multiple models can be used together", (done) => { + class SuccessfulClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + const rootNodes = parameters.map((params) => { + const node = loadModelsFromJson([JSON.stringify({ root: {} })]); + node.setPropertyValue(toRoleJS("modelId"), params.branchId); + return node; + }); + + const branch = { + rootNode: rootNodes[0], + getRootNodes: () => rootNodes, + addListener: jest.fn(), + removeListener: jest.fn(), + resolveNode: jest.fn(), + }; + + const replicatedModel = { + getBranch: () => branch, + dispose: jest.fn(), + getCurrentVersionInformation: jest.fn(), + } as unknown as ReplicatedModelJS; + + return Promise.resolve(replicatedModel); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + + const modelsParams = ref([ + new org.modelix.model.client2.ReplicatedModelParameters( + "repo1", + "branch1", + IdSchemeJS.MODELIX, + ), + new org.modelix.model.client2.ReplicatedModelParameters( + "repo2", + "branch2", + IdSchemeJS.MODELIX, + ), + ]); + + const { rootNodes } = useReplicatedModels(client, modelsParams); + + watchEffect(() => { + if (rootNodes.value.length === 2) { + expect(rootNodes.value[0].getPropertyValue(toRoleJS("modelId"))).toBe("branch1"); + expect(rootNodes.value[1].getPropertyValue(toRoleJS("modelId"))).toBe("branch2"); + done(); + } + }); + }); + + test("changes in composite model trigger reactivity", (done) => { + class SuccessfulClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + const rootNodes = parameters.map((params) => { + const node = loadModelsFromJson([JSON.stringify({ root: {} })]); + node.setPropertyValue(toRoleJS("counter"), "0"); + return node; + }); + + let listener: ((change: any) => void) | null = null; + + const branch = { + rootNode: rootNodes[0], + getRootNodes: () => rootNodes, + addListener: (fn: (change: any) => void) => { + listener = fn; + }, + removeListener: jest.fn(), + resolveNode: jest.fn(), + }; + + const replicatedModel = { + getBranch: () => branch, + dispose: jest.fn(), + getCurrentVersionInformation: jest.fn(), + } as unknown as ReplicatedModelJS; + + // Simulate property change in second model + setTimeout(() => { + rootNodes[1].setPropertyValue(toRoleJS("counter"), "1"); + if (listener) { + listener({ + node: rootNodes[1], + role: "counter", + constructor: { name: "PropertyChanged" }, + }); + } + }, 50); + + return Promise.resolve(replicatedModel); + } + } + + const { client } = useModelClient("anURL", () => + Promise.resolve(new SuccessfulClientJS() as unknown as ClientJS), + ); + + const modelsParams = ref([ + new org.modelix.model.client2.ReplicatedModelParameters( + "repo1", + "branch1", + IdSchemeJS.MODELIX, + ), + new org.modelix.model.client2.ReplicatedModelParameters( + "repo2", + "branch2", + IdSchemeJS.MODELIX, + ), + ]); + + const { rootNodes } = useReplicatedModels(client, modelsParams); + + const computedValue = computed(() => { + if (rootNodes.value.length >= 2) { + return rootNodes.value[1].getPropertyValue(toRoleJS("counter")); + } + return null; + }); + + watchEffect(() => { + if (computedValue.value === "1") { + expect(computedValue.value).toBe("1"); + done(); + } + }); + }); +}); diff --git a/vue-model-api/src/useReplicatedModel.ts b/vue-model-api/src/useReplicatedModels.ts similarity index 56% rename from vue-model-api/src/useReplicatedModel.ts rename to vue-model-api/src/useReplicatedModels.ts index 4c42060578..137174b35b 100644 --- a/vue-model-api/src/useReplicatedModel.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -1,8 +1,8 @@ -import type { org } from "@modelix/model-client"; +import { org } from "@modelix/model-client"; import type { INodeJS } from "@modelix/ts-model-api"; import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; import type { MaybeRefOrGetter, Ref } from "vue"; -import { shallowRef, toValue } from "vue"; +import { shallowRef, toValue, computed } from "vue"; import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { Cache } from "./internal/Cache"; @@ -11,49 +11,46 @@ import { handleChange } from "./internal/handleChange"; type ClientJS = org.modelix.model.client2.ClientJS; type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ChangeJS = org.modelix.model.client2.ChangeJS; +type ReplicatedModelParameters = + org.modelix.model.client2.ReplicatedModelParameters; function isDefined(value: T | null | undefined): value is T { return value !== null && value !== undefined; } /** - * Creates a replicated model for a given repository and branch. + * Creates replicated models for the given repositories and branches. * A replicated model exposes a branch that can be used to read and write model data. * The written model data is automatically synced to the model server. - * Changed from the model server are automatically synced to the branch in the replicated model + * Changes from the model server are automatically synced to the branch in the replicated model. * - * Also creates root node that uses Vues reactivity and can be used in Vue like a reactive object. + * Also creates root nodes that use Vue's reactivity and can be used in Vue like reactive objects. * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. * - * Calling the returned dispose function stops syncing the root node to the underlying branch on the server. + * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. * * @param client - Reactive reference of a client to a model server. - * @param repositoryId - Reactive reference of a repositoryId on the model server. - * @param branchId - Reactive reference of a branchId in the repository of the model server. + * @param models - Reactive reference to an array of ReplicatedModelParameters. * * @returns {Object} values Wrapper around different returned values. - * @returns {Ref} values.rootNode Reactive reference to the replicated model for the specified branch. - * @returns {Ref} values.rootNode Reactive reference to the root node with Vue.js reactivity for the specified branch. - * @returns {() => void} values.dispose A function to manually dispose the root node. + * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. + * @returns {Ref} values.rootNodes Reactive reference to an array of root nodes with Vue.js reactivity for the specified branches. + * @returns {() => void} values.dispose A function to manually dispose the root nodes. * @returns {Ref} values.error Reactive reference to a connection error. */ -export function useReplicatedModel( +export function useReplicatedModels( client: MaybeRefOrGetter, - repositoryId: MaybeRefOrGetter, - branchId: MaybeRefOrGetter, - idScheme: MaybeRefOrGetter< - org.modelix.model.client2.IdSchemeJS | null | undefined - >, + models: MaybeRefOrGetter, ): { replicatedModel: Ref; - rootNode: Ref; + rootNodes: Ref; dispose: () => void; error: Ref; } { // Use `replicatedModel` to access the replicated model without tracking overhead of Vue.js. let replicatedModel: ReplicatedModelJS | null = null; const replicatedModelRef: Ref = shallowRef(null); - const rootNodeRef: Ref = shallowRef(null); + const rootNodesRef: Ref = shallowRef([]); const errorRef: Ref = shallowRef(null); const dispose = () => { @@ -63,32 +60,27 @@ export function useReplicatedModel( replicatedModel.dispose(); } replicatedModelRef.value = null; - rootNodeRef.value = null; + rootNodesRef.value = []; errorRef.value = null; }; - useLastPromiseEffect( + useLastPromiseEffect<{ + replicatedModel: ReplicatedModelJS; + cache: Cache; + }>( () => { dispose(); const clientValue = toValue(client); if (!isDefined(clientValue)) { return; } - const repositoryIdValue = toValue(repositoryId); - if (!isDefined(repositoryIdValue)) { - return; - } - const branchIdValue = toValue(branchId); - if (!isDefined(branchIdValue)) { - return; - } - const idSchemeValue = toValue(idScheme); - if (!isDefined(idSchemeValue)) { + const modelsValue = toValue(models); + if (!isDefined(modelsValue)) { return; } const cache = new Cache(); return clientValue - .startReplicatedModel(repositoryIdValue, branchIdValue, idSchemeValue) + .startReplicatedModels(modelsValue) .then((replicatedModel) => ({ replicatedModel, cache })); }, ( @@ -104,10 +96,12 @@ export function useReplicatedModel( } handleChange(change, cache); }); - const unreactiveRootNode = branch.rootNode; - const reactiveRootNode = toReactiveINodeJS(unreactiveRootNode, cache); + const unreactiveRootNodes = branch.getRootNodes(); + const reactiveRootNodes = unreactiveRootNodes.map((node) => + toReactiveINodeJS(node, cache), + ); replicatedModelRef.value = replicatedModel; - rootNodeRef.value = reactiveRootNode; + rootNodesRef.value = reactiveRootNodes; } else { connectedReplicatedModel.dispose(); } @@ -121,8 +115,73 @@ export function useReplicatedModel( return { replicatedModel: replicatedModelRef, - rootNode: rootNodeRef, + rootNodes: rootNodesRef, dispose, error: errorRef, }; } + +/** + * Creates a replicated model for a given repository and branch. + * A replicated model exposes a branch that can be used to read and write model data. + * The written model data is automatically synced to the model server. + * Changed from the model server are automatically synced to the branch in the replicated model + * + * Also creates root node that uses Vues reactivity and can be used in Vue like a reactive object. + * Changes to model data trigger recalculation of computed properties or re-rendering of components using that data. + * + * Calling the returned dispose function stops syncing the root node to the underlying branch on the server. + * + * @param client - Reactive reference of a client to a model server. + * @param repositoryId - Reactive reference of a repositoryId on the model server. + * @param branchId - Reactive reference of a branchId in the repository of the model server. + * + * @returns {Object} values Wrapper around different returned values. + * @returns {Ref} values.rootNode Reactive reference to the replicated model for the specified branch. + * @returns {Ref} values.rootNode Reactive reference to the root node with Vue.js reactivity for the specified branch. + * @returns {() => void} values.dispose A function to manually dispose the root node. + * @returns {Ref} values.error Reactive reference to a connection error. + * + * @deprecated Use {@link useReplicatedModels} instead. + */ +export function useReplicatedModel( + client: MaybeRefOrGetter, + repositoryId: MaybeRefOrGetter, + branchId: MaybeRefOrGetter, + idScheme: MaybeRefOrGetter< + org.modelix.model.client2.IdSchemeJS | null | undefined + >, +): { + replicatedModel: Ref; + rootNode: Ref; + dispose: () => void; + error: Ref; +} { + const models = computed(() => { + const repositoryIdValue = toValue(repositoryId); + const branchIdValue = toValue(branchId); + const idSchemeValue = toValue(idScheme); + if (!repositoryIdValue || !branchIdValue || !idSchemeValue) { + return null; + } + return [ + new org.modelix.model.client2.ReplicatedModelParameters( + repositoryIdValue, + branchIdValue, + idSchemeValue, + ), + ]; + }); + + const result = useReplicatedModels(client, models); + + // Extract the single root node from the array for backward compatibility + const rootNode = computed(() => result.rootNodes.value[0] ?? null); + + return { + replicatedModel: result.replicatedModel, + rootNode: rootNode, + dispose: result.dispose, + error: result.error, + }; +}