From 17557cc322bc1f42647412931dc8d909c8ed1cc4 Mon Sep 17 00:00:00 2001 From: Arthur Daussy Date: Tue, 19 May 2026 16:53:16 +0200 Subject: [PATCH] [2182] All downstream applications to extend ISysMLMoveElementService Bug: https://github.com/eclipse-syson/syson/issues/2182 Signed-off-by: Arthur Daussy --- CHANGELOG.adoc | 2 + ...lementServiceDelegateIntegrationTests.java | 180 ++++++++++++++++++ .../data/GeneralViewFlowUsageProjectData.java | 8 +- .../DefaultSysMLMoveElementService.java | 138 ++++++++++++++ .../SysMLMoveElementCheckerService.java | 56 ++++++ .../services/SysMLMoveElementService.java | 137 +++---------- .../api/IDefaultSysMLMoveElementService.java | 36 ++++ .../api/ISysMLMoveElementCheckerService.java | 36 ++++ .../api/ISysMLMoveElementServiceDelegate.java | 47 +++++ .../services/SysMLMoveElementServiceTest.java | 157 ++++++++++++++- 10 files changed, 680 insertions(+), 117 deletions(-) create mode 100644 backend/application/syson-application/src/test/java/org/eclipse/syson/SysMLMoveElementServiceDelegateIntegrationTests.java create mode 100644 backend/services/syson-services/src/main/java/org/eclipse/syson/services/DefaultSysMLMoveElementService.java create mode 100644 backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementCheckerService.java create mode 100644 backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/IDefaultSysMLMoveElementService.java create mode 100644 backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementCheckerService.java create mode 100644 backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementServiceDelegate.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 3cf9e1aa8..bdf49dfd4 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -20,6 +20,8 @@ - https://github.com/eclipse-syson/syson/issues/2198[#2198] [diagrams] Improve diagram-to-diagram drag and drop to support dropping multiple graphical nodes at once, leveraging Sirius Web's `droppedNodes` and `droppedElements` variables. - https://github.com/eclipse-syson/syson/issues/2194[#2194] [diagrams] Properly report feedback messages to user when using _ISysMLMoveElementService_. +- https://github.com/eclipse-syson/syson/issues/2182[#2182] [services] Provide a way for downstream applications to extend _ISysMLMoveElementService_; + === New features diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/SysMLMoveElementServiceDelegateIntegrationTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/SysMLMoveElementServiceDelegateIntegrationTests.java new file mode 100644 index 000000000..9c48732fd --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/SysMLMoveElementServiceDelegateIntegrationTests.java @@ -0,0 +1,180 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.sirius.components.diagrams.tests.DiagramEventPayloadConsumer.assertRefreshedDiagramThat; + +import com.jayway.jsonpath.JsonPath; + +import java.text.MessageFormat; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import org.eclipse.sirius.components.collaborative.diagrams.dto.DiagramEventInput; +import org.eclipse.sirius.components.collaborative.diagrams.dto.DiagramRefreshedEventPayload; +import org.eclipse.sirius.components.collaborative.diagrams.dto.DropNodesInput; +import org.eclipse.sirius.components.core.api.IFeedbackMessageService; +import org.eclipse.sirius.components.core.api.SuccessPayload; +import org.eclipse.sirius.components.diagrams.Diagram; +import org.eclipse.sirius.components.diagrams.layoutdata.Position; +import org.eclipse.sirius.components.representations.Message; +import org.eclipse.sirius.components.representations.MessageLevel; +import org.eclipse.sirius.web.tests.services.api.IGivenInitialServerState; +import org.eclipse.syson.application.controllers.diagrams.testers.DropNodesWithMessageMutationRunner; +import org.eclipse.syson.application.data.GeneralViewFlowUsageProjectData; +import org.eclipse.syson.services.api.IDefaultSysMLMoveElementService; +import org.eclipse.syson.services.api.ISysMLMoveElementServiceDelegate; +import org.eclipse.syson.services.api.MoveStatus; +import org.eclipse.syson.services.diagrams.api.IGivenDiagramSubscription; +import org.eclipse.syson.sysml.Element; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +/** + * Integration tests for {@link ISysMLMoveElementServiceDelegate}. + * + * @author Arthur Daussy + */ +@Transactional +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(SysMLMoveElementServiceDelegateIntegrationTests.SysMLMoveElementServiceDelegateTestConfiguration.class) +public class SysMLMoveElementServiceDelegateIntegrationTests extends AbstractIntegrationTests { + + private static final String DROP_NODES_TYPENAME = "$.data.dropNodes.__typename"; + + private static final String DROP_NODES_MESSAGE_BODY = "$.data.dropNodes.messages[0].body"; + + private static final String DROP_NODES_MESSAGE_LEVEL = "$.data.dropNodes.messages[0].level"; + + private static final String EXPECTED_FEEDBACK_MESSAGE = "Moved screen to Package1"; + + @Autowired + private IGivenInitialServerState givenInitialServerState; + + @Autowired + private IGivenDiagramSubscription givenDiagramSubscription; + + @Autowired + private DropNodesWithMessageMutationRunner dropNodeRunner; + + private Flux givenSubscriptionToDiagram() { + var diagramEventInput = new DiagramEventInput(UUID.randomUUID(), + GeneralViewFlowUsageProjectData.EDITING_CONTEXT_ID, + GeneralViewFlowUsageProjectData.GraphicalIds.DIAGRAM_ID); + return this.givenDiagramSubscription.subscribe(diagramEventInput); + } + + @BeforeEach + public void beforeEach() { + this.givenInitialServerState.initialize(); + } + + @DisplayName("GIVEN a move delegate, WHEN Screen is dropped into Package 1, THEN the delegate moves it and returns a feedback message") + @GivenSysONServer({ GeneralViewFlowUsageProjectData.SCRIPT_PATH }) + @Test + public void moveScreenPartWithDelegate() { + var flux = this.givenSubscriptionToDiagram(); + + AtomicReference diagram = new AtomicReference<>(); + + Consumer initialDiagramContentConsumer = assertRefreshedDiagramThat(diagram::set); + + Runnable dropScreenPartFromDiagramToPackage = () -> { + var input = new DropNodesInput( + UUID.randomUUID(), + GeneralViewFlowUsageProjectData.EDITING_CONTEXT_ID, + GeneralViewFlowUsageProjectData.GraphicalIds.DIAGRAM_ID, + List.of(GeneralViewFlowUsageProjectData.GraphicalIds.SCREEN_PART_ID), + null, + List.of(new Position(0, 0))); + var result = this.dropNodeRunner.run(input); + String typename = JsonPath.read(result.data(), DROP_NODES_TYPENAME); + assertThat(typename).isEqualTo(SuccessPayload.class.getSimpleName()); + String messageBody = JsonPath.read(result.data(), DROP_NODES_MESSAGE_BODY); + assertThat(messageBody).isEqualTo(EXPECTED_FEEDBACK_MESSAGE); + String messageLevel = JsonPath.read(result.data(), DROP_NODES_MESSAGE_LEVEL); + assertThat(messageLevel).isEqualTo(MessageLevel.INFO.toString()); + }; + + Consumer updatedDiagramContentConsumer = assertRefreshedDiagramThat(newDiagram -> { + assertThat(newDiagram.getNodes()) + .extracting(org.eclipse.sirius.components.diagrams.Node::getTargetObjectId) + .contains(GeneralViewFlowUsageProjectData.SemanticIds.SCREEN_PART); + }); + + StepVerifier.create(flux) + .consumeNextWith(initialDiagramContentConsumer) + .then(dropScreenPartFromDiagramToPackage) + .consumeNextWith(updatedDiagramContentConsumer) + .thenCancel() + .verify(Duration.ofSeconds(10)); + } + + /** + * Test configuration used to activate the move delegate only for this integration test. + */ + @TestConfiguration + static class SysMLMoveElementServiceDelegateTestConfiguration { + + @Bean + ISysMLMoveElementServiceDelegate feedbackMoveElementServiceDelegate(IDefaultSysMLMoveElementService defaultMoveElementService, IFeedbackMessageService feedbackMessageService) { + return new FeedbackMoveElementServiceDelegate(defaultMoveElementService, feedbackMessageService); + } + } + + /** + * Test move delegate which behaves like the default implementation and emits a feedback message. + */ + private static final class FeedbackMoveElementServiceDelegate implements ISysMLMoveElementServiceDelegate { + + private final IDefaultSysMLMoveElementService defaultMoveElementService; + + private final IFeedbackMessageService feedbackMessageService; + + FeedbackMoveElementServiceDelegate(IDefaultSysMLMoveElementService defaultMoveElementService, IFeedbackMessageService feedbackMessageService) { + this.defaultMoveElementService = Objects.requireNonNull(defaultMoveElementService); + this.feedbackMessageService = Objects.requireNonNull(feedbackMessageService); + } + + @Override + public boolean canHandle(Element element, Element newParent) { + return true; + } + + @Override + public MoveStatus moveSemanticElement(Element element, Element newParent) { + MoveStatus moveStatus = this.defaultMoveElementService.moveSemanticElement(element, newParent); + if (moveStatus.isSuccess() && element != newParent) { + String elementLabel = element.getDeclaredName(); + String parentLabel = newParent.getDeclaredName(); + this.feedbackMessageService.addFeedbackMessage(new Message(MessageFormat.format("Moved {0} to {1}", elementLabel, parentLabel), MessageLevel.INFO)); + } + return moveStatus; + } + } +} diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/data/GeneralViewFlowUsageProjectData.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/data/GeneralViewFlowUsageProjectData.java index bddca295e..372c48175 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/data/GeneralViewFlowUsageProjectData.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/data/GeneralViewFlowUsageProjectData.java @@ -23,12 +23,14 @@ public class GeneralViewFlowUsageProjectData { public static final String SCRIPT_PATH = "/scripts/database-content/GeneralView-FlowUsage.sql"; /** - * Ids of the graphical elements elements. + * Ids of the graphical elements. */ public static final class GraphicalIds { public static final String DIAGRAM_ID = "2a1b62cf-36ea-41d2-8433-8ff781b3f4e5"; public static final String CONNECTION_EDGE_ID = "7f3367d5-a200-339b-820a-2408e5b82c84"; + + public static final String SCREEN_PART_ID = "946dc191-c8df-3f97-96a9-02cce98768f4"; } /** @@ -41,5 +43,9 @@ public static final class SemanticIds { public static final String VIDEO_SIGNAL_ID = "d9b2dd49-4bae-44a7-8137-73bcaf0a2d5e"; + public static final String SCREEN_PART = "7776b0d9-287c-441f-b203-36b7ba41a7c6"; + + public static final String PACKAGE_1 = "8cb4376f-fe0d-4501-abcb-05eac69e766c"; + } } diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/DefaultSysMLMoveElementService.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/DefaultSysMLMoveElementService.java new file mode 100644 index 000000000..45e1a3ff7 --- /dev/null +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/DefaultSysMLMoveElementService.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * Copyright (c) 2025, 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.services; + +import java.util.Objects; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.syson.services.api.IDefaultSysMLMoveElementService; +import org.eclipse.syson.services.api.ISysMLMoveElementCheckerService; +import org.eclipse.syson.services.api.MoveStatus; +import org.eclipse.syson.sysml.Element; +import org.eclipse.syson.sysml.FeatureMembership; +import org.eclipse.syson.sysml.Import; +import org.eclipse.syson.sysml.Membership; +import org.eclipse.syson.sysml.MembershipExpose; +import org.eclipse.syson.sysml.OwningMembership; +import org.eclipse.syson.sysml.Package; +import org.eclipse.syson.sysml.SysmlFactory; +import org.eclipse.syson.sysml.SysmlPackage; +import org.springframework.stereotype.Service; + +/** + * Default {@link IDefaultSysMLMoveElementService} for SysML elements. + * + * @author Arthur Daussy + */ +@Service +public class DefaultSysMLMoveElementService implements IDefaultSysMLMoveElementService { + + private final DeleteService deleteService; + + private final UtilService utilService; + + private final ISysMLMoveElementCheckerService moveElementCheckerService; + + public DefaultSysMLMoveElementService(ISysMLMoveElementCheckerService moveElementCheckerService) { + this.moveElementCheckerService = Objects.requireNonNull(moveElementCheckerService); + this.deleteService = new DeleteService(); + this.utilService = new UtilService(); + } + + @Override + public MoveStatus moveSemanticElement(Element element, Element newParent) { + final MoveStatus moveStatus; + MoveStatus canMoveStatus = this.moveElementCheckerService.canMove(element, newParent); + if (!canMoveStatus.isSuccess()) { + moveStatus = canMoveStatus; + } else if (element == newParent) { + moveStatus = MoveStatus.buildSuccess(); + } else if (newParent instanceof Membership) { + moveStatus = MoveStatus.buildFailure("Membership can't be used as a target of a move"); + } else if (element instanceof Import imprt) { + newParent.getOwnedRelationship().add(0, imprt); + moveStatus = MoveStatus.buildSuccess(); + } else { + moveStatus = this.moveWithMembership(element, newParent); + } + return moveStatus; + } + + /** + * Moves the owning membership of {@code element} to {@code parent}. + *

+ * This method may create a new membership in {@code parent}, potentially with a different type than + * {@code element.getOwningMembership()}. For example, an element moved into a package will have an + * {@link OwningMembership} instance as its parent, regardless of its original containing membership. + *

+ * + * @param element + * the element that has been dropped + * @param parent + * the element inside which the drop has been performed + * @return true if the given element has been moved + */ + private MoveStatus moveWithMembership(Element element, Element parent) { + final MoveStatus moveStatus; + + if (element != null && element.eContainer() instanceof Membership currentMembership) { + if (parent instanceof Package) { + // the expected membership should be an OwningMembership + if (currentMembership instanceof FeatureMembership) { + var owningMembership = SysmlFactory.eINSTANCE.createOwningMembership(); + // Add the new membership to its container first to make sure its content stays in the same + // resource. Otherwise the cross-referencer will delete all the references pointing to its + // related element, which will have unexpected results on the model. + parent.getOwnedRelationship().add(owningMembership); + moveStatus = MoveStatus.buildSuccess(); + owningMembership.getOwnedRelatedElement().add(element); + // If the currentMembership is exposed, we need to add new links between the MembershipExposes and + // the new owningMembership + var eInverseRelatedElements = this.utilService.getEInverseRelatedElements(currentMembership, SysmlPackage.eINSTANCE.getMembershipImport_ImportedMembership()); + for (EObject eObject : eInverseRelatedElements) { + if (eObject instanceof MembershipExpose membershipExpose) { + membershipExpose.setImportedMembership(owningMembership); + } + } + this.deleteService.deleteFromModel(currentMembership); + } else { + parent.getOwnedRelationship().add(currentMembership); + moveStatus = MoveStatus.buildSuccess(); + } + } else { + // the expected membership should be a FeatureMembership + if (currentMembership instanceof FeatureMembership) { + parent.getOwnedRelationship().add(currentMembership); + moveStatus = MoveStatus.buildSuccess(); + } else { + var featureMembership = SysmlFactory.eINSTANCE.createFeatureMembership(); + parent.getOwnedRelationship().add(featureMembership); + moveStatus = MoveStatus.buildSuccess(); + featureMembership.getOwnedRelatedElement().add(element); + // If the currentMembership is exposed, we need to add new links between the MembershipExposes and + // the new featureMembership + var eInverseRelatedElements = this.utilService.getEInverseRelatedElements(currentMembership, SysmlPackage.eINSTANCE.getMembershipImport_ImportedMembership()); + for (EObject eObject : eInverseRelatedElements) { + if (eObject instanceof MembershipExpose membershipExpose) { + membershipExpose.setImportedMembership(featureMembership); + } + } + this.deleteService.deleteFromModel(currentMembership); + } + } + } else { + moveStatus = MoveStatus.buildFailure("Element is not contained in a membership"); + } + return moveStatus; + } +} diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementCheckerService.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementCheckerService.java new file mode 100644 index 000000000..ee97b02a5 --- /dev/null +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementCheckerService.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.services; + +import java.util.Objects; + +import org.eclipse.sirius.components.core.api.IReadOnlyObjectPredicate; +import org.eclipse.syson.services.api.ISysMLMoveElementCheckerService; +import org.eclipse.syson.services.api.MoveStatus; +import org.eclipse.syson.sysml.Element; +import org.eclipse.syson.sysml.helper.EMFUtils; +import org.springframework.stereotype.Service; + +/** + * Default implementation of {@link ISysMLMoveElementCheckerService}. + * + * @author Arthur Daussy + */ +@Service +public class SysMLMoveElementCheckerService implements ISysMLMoveElementCheckerService { + + private final IReadOnlyObjectPredicate readOnlyObjectPredicate; + + public SysMLMoveElementCheckerService(IReadOnlyObjectPredicate readOnlyObjectPredicate) { + this.readOnlyObjectPredicate = Objects.requireNonNull(readOnlyObjectPredicate); + } + + @Override + public MoveStatus canMove(Element element, Element newParent) { + final MoveStatus moveStatus; + if (element == newParent) { + // DnD is quite sensitive in the frontend, we want to avoid sending a message each time a user do a micro + // DnD on the item itself. Instead we ignore the DnD. Drawbacks: the model is persisted in DB. + moveStatus = MoveStatus.buildSuccess(); + } else if (EMFUtils.isAncestor(element, newParent)) { + moveStatus = MoveStatus.buildFailure("Unable to move an Element to one of its descendant"); + } else if (this.readOnlyObjectPredicate.test(element)) { + moveStatus = MoveStatus.buildFailure("Unable to move a read only Element"); + } else if (this.readOnlyObjectPredicate.test(newParent)) { + moveStatus = MoveStatus.buildFailure("Unable to move a Element to a read only Element"); + } else { + moveStatus = MoveStatus.buildSuccess(); + } + return moveStatus; + } +} diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementService.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementService.java index fd68462ca..1e24735de 100644 --- a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementService.java +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/SysMLMoveElementService.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Obeo. + * Copyright (c) 2025, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -12,42 +12,36 @@ *******************************************************************************/ package org.eclipse.syson.services; +import java.util.List; import java.util.Objects; +import java.util.Optional; -import org.eclipse.emf.ecore.EObject; -import org.eclipse.sirius.components.core.api.IReadOnlyObjectPredicate; +import org.eclipse.syson.services.api.IDefaultSysMLMoveElementService; import org.eclipse.syson.services.api.ISysMLMoveElementService; +import org.eclipse.syson.services.api.ISysMLMoveElementServiceDelegate; import org.eclipse.syson.services.api.MoveStatus; import org.eclipse.syson.sysml.Element; -import org.eclipse.syson.sysml.FeatureMembership; -import org.eclipse.syson.sysml.Import; -import org.eclipse.syson.sysml.Membership; -import org.eclipse.syson.sysml.MembershipExpose; -import org.eclipse.syson.sysml.OwningMembership; -import org.eclipse.syson.sysml.Package; -import org.eclipse.syson.sysml.SysmlFactory; -import org.eclipse.syson.sysml.SysmlPackage; -import org.eclipse.syson.sysml.helper.EMFUtils; import org.springframework.stereotype.Service; /** - * {@link ISysMLMoveElementService} for SysML elements. + * Implementation of {@link ISysMLMoveElementService} which delegates to {@link ISysMLMoveElementServiceDelegate}. + *

+ * {@link IDefaultSysMLMoveElementService} is used as fallback if there is no + * {@link ISysMLMoveElementServiceDelegate} to delegate to. + *

* * @author Arthur Daussy */ @Service public class SysMLMoveElementService implements ISysMLMoveElementService { - private final IReadOnlyObjectPredicate readOnlyObjectPredicate; + private final List moveElementServiceDelegates; - private final DeleteService deleteService; + private final IDefaultSysMLMoveElementService defaultMoveElementService; - private final UtilService utilService; - - public SysMLMoveElementService(IReadOnlyObjectPredicate readOnlyObjectPredicate) { - this.readOnlyObjectPredicate = Objects.requireNonNull(readOnlyObjectPredicate); - this.deleteService = new DeleteService(); - this.utilService = new UtilService(); + public SysMLMoveElementService(List moveElementServiceDelegates, IDefaultSysMLMoveElementService defaultMoveElementService) { + this.moveElementServiceDelegates = Objects.requireNonNull(moveElementServiceDelegates); + this.defaultMoveElementService = Objects.requireNonNull(defaultMoveElementService); } /** @@ -61,101 +55,14 @@ public SysMLMoveElementService(IReadOnlyObjectPredicate readOnlyObjectPredicate) */ @Override public MoveStatus moveSemanticElement(Element element, Element newParent) { - final MoveStatus moveStatus; - if (element == newParent) { - // DnD is quite sensitive in the frontend, we want to avoid sending a message each time a user do a micro - // DnD on the item itself. Instead we ignore the DnD. Drawbacks: the model is persisted in DB. - moveStatus = MoveStatus.buildSuccess(); - } else if (EMFUtils.isAncestor(element, newParent)) { - moveStatus = MoveStatus.buildFailure("Unable to move an Element to one of its descendant"); - } else if (newParent instanceof Membership) { - moveStatus = MoveStatus.buildFailure("Membership can't be used as a target of a move"); - } else if (this.readOnlyObjectPredicate.test(element)) { - moveStatus = MoveStatus.buildFailure("Unable to move a read only Element"); - } else if (this.readOnlyObjectPredicate.test(newParent)) { - moveStatus = MoveStatus.buildFailure("Unable to move a Element to a read only Element"); - } else { - moveStatus = this.doMoveElement(element, newParent); - } - return moveStatus; - } - - /** - * Moves the owning membership of {@code element} to {@code parent}. - *

- * This method may create a new membership in {@code parent}, potentially with a different type than - * {@code element.getOwningMembership()}. For example, an element moved into a package will have an - * {@link OwningMembership} instance as its parent, regardless of its original containing membership. - *

- * - * @param element - * the element that has been dropped - * @param parent - * the element inside which the drop has been performed - * @return true if the given element has been moved - */ - private MoveStatus moveWithMembership(Element element, Element parent) { - final MoveStatus moveStatus; - - if (element.eContainer() instanceof Membership currentMembership) { - if (parent instanceof Package) { - // the expected membership should be an OwningMembership - if (currentMembership instanceof FeatureMembership) { - var owningMembership = SysmlFactory.eINSTANCE.createOwningMembership(); - // Add the new membership to its container first to make sure its content stays in the same - // resource. Otherwise the cross-referencer will delete all the references pointing to its - // related element, which will have unexpected results on the model. - parent.getOwnedRelationship().add(owningMembership); - moveStatus = MoveStatus.buildSuccess(); - owningMembership.getOwnedRelatedElement().add(element); - // If the currentMembership is exposed, we need to add new links between the MembershipExposes and - // the new owningMembership - var eInverseRelatedElements = this.utilService.getEInverseRelatedElements(currentMembership, SysmlPackage.eINSTANCE.getMembershipImport_ImportedMembership()); - for (EObject eObject : eInverseRelatedElements) { - if (eObject instanceof MembershipExpose membershipExpose) { - membershipExpose.setImportedMembership(owningMembership); - } - } - this.deleteService.deleteFromModel(currentMembership); - } else { - parent.getOwnedRelationship().add(currentMembership); - moveStatus = MoveStatus.buildSuccess(); - } - } else { - // the expected membership should be a FeatureMembership - if (currentMembership instanceof FeatureMembership) { - parent.getOwnedRelationship().add(currentMembership); - moveStatus = MoveStatus.buildSuccess(); - } else { - var featureMembership = SysmlFactory.eINSTANCE.createFeatureMembership(); - parent.getOwnedRelationship().add(featureMembership); - moveStatus = MoveStatus.buildSuccess(); - featureMembership.getOwnedRelatedElement().add(element); - // If the currentMembership is exposed, we need to add new links between the MembershipExposes and - // the new featureMembership - var eInverseRelatedElements = this.utilService.getEInverseRelatedElements(currentMembership, SysmlPackage.eINSTANCE.getMembershipImport_ImportedMembership()); - for (EObject eObject : eInverseRelatedElements) { - if (eObject instanceof MembershipExpose membershipExpose) { - membershipExpose.setImportedMembership(featureMembership); - } - } - this.deleteService.deleteFromModel(currentMembership); - } - } - } else { - moveStatus = MoveStatus.buildFailure("Element is not contained in a membership"); - } - return moveStatus; + return this.getDelegate(element, newParent) + .map(delegate -> delegate.moveSemanticElement(element, newParent)) + .orElseGet(() -> this.defaultMoveElementService.moveSemanticElement(element, newParent)); } - private MoveStatus doMoveElement(Element element, Element newParent) { - final MoveStatus moveStatus; - if (element instanceof Import imprt) { - newParent.getOwnedRelationship().add(0, imprt); - moveStatus = MoveStatus.buildSuccess(); - } else { - moveStatus = this.moveWithMembership(element, newParent); - } - return moveStatus; + private Optional getDelegate(Element element, Element newParent) { + return this.moveElementServiceDelegates.stream() + .filter(delegate -> delegate.canHandle(element, newParent)) + .findFirst(); } } diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/IDefaultSysMLMoveElementService.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/IDefaultSysMLMoveElementService.java new file mode 100644 index 000000000..da9b6e46c --- /dev/null +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/IDefaultSysMLMoveElementService.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.services.api; + +import org.eclipse.syson.sysml.Element; + +/** + * Default implementation used to move SysML elements. + * + * @author Arthur Daussy + */ +public interface IDefaultSysMLMoveElementService { + + /** + * Moves an element into a new parent. + * + * @param element + * the element to move + * @param newParent + * the new parent + * @return a success {@link MoveStatus} if the element has been moved, a failing {@link MoveStatus} with an + * explanation message otherwise + */ + MoveStatus moveSemanticElement(Element element, Element newParent); + +} diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementCheckerService.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementCheckerService.java new file mode 100644 index 000000000..d104c72d7 --- /dev/null +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementCheckerService.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.services.api; + +import org.eclipse.syson.sysml.Element; + +/** + * Service used to check if SysML elements can be moved. + * + * @author Arthur Daussy + */ +public interface ISysMLMoveElementCheckerService { + + /** + * Checks if an element can be moved into a new parent. + * + * @param element + * the element to move + * @param newParent + * the new parent + * @return a success {@link MoveStatus} if the element can be moved, a failing {@link MoveStatus} with an + * explanation message otherwise + */ + MoveStatus canMove(Element element, Element newParent); + +} diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementServiceDelegate.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementServiceDelegate.java new file mode 100644 index 000000000..47a312524 --- /dev/null +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/api/ISysMLMoveElementServiceDelegate.java @@ -0,0 +1,47 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.services.api; + +import org.eclipse.syson.sysml.Element; + +/** + * Interface of the delegation service used to move SysML elements in specific cases. + * + * @author Arthur Daussy + */ +public interface ISysMLMoveElementServiceDelegate { + + /** + * Indicates whether this delegate can move the given element to the given parent. + * + * @param element + * the element to move + * @param newParent + * the new parent + * @return true if this delegate can handle the move, false otherwise + */ + boolean canHandle(Element element, Element newParent); + + /** + * Moves an element into a new parent. + * + * @param element + * the element to move + * @param newParent + * the new parent + * @return a success {@link MoveStatus} if the element has been moved, a failing {@link MoveStatus} with an + * explanation message otherwise + */ + MoveStatus moveSemanticElement(Element element, Element newParent); + +} diff --git a/backend/services/syson-services/src/test/java/org/eclipse/syson/services/SysMLMoveElementServiceTest.java b/backend/services/syson-services/src/test/java/org/eclipse/syson/services/SysMLMoveElementServiceTest.java index 7abcc0c5e..94f9777ee 100644 --- a/backend/services/syson-services/src/test/java/org/eclipse/syson/services/SysMLMoveElementServiceTest.java +++ b/backend/services/syson-services/src/test/java/org/eclipse/syson/services/SysMLMoveElementServiceTest.java @@ -25,6 +25,10 @@ import org.eclipse.emf.ecore.resource.impl.ResourceSetImpl; import org.eclipse.emf.ecore.util.ECrossReferenceAdapter; import org.eclipse.sirius.emfjson.resource.JsonResourceFactoryImpl; +import org.eclipse.syson.services.api.IDefaultSysMLMoveElementService; +import org.eclipse.syson.services.api.ISysMLMoveElementCheckerService; +import org.eclipse.syson.services.api.ISysMLMoveElementServiceDelegate; +import org.eclipse.syson.services.api.MoveStatus; import org.eclipse.syson.sysml.Element; import org.eclipse.syson.sysml.FeatureMembership; import org.eclipse.syson.sysml.FeatureTyping; @@ -56,7 +60,7 @@ public class SysMLMoveElementServiceTest extends AbstractServiceTest { @BeforeEach public void setUp() { this.readOnlyElements = new ArrayList<>(); - this.moveService = new SysMLMoveElementService(e -> this.readOnlyElements.contains(e)); + this.moveService = new SysMLMoveElementService(List.of(), this.createDefaultMoveElementService()); this.rSet = new ResourceSetImpl(); // Make sure the resources we manipulate use a CrossReferenceAdapter. this.rSet.eAdapters().add(new ECrossReferenceAdapter()); @@ -181,10 +185,161 @@ public void moveMembershipOfTypedPartUsageInPackageToPartDefinition() { .returns(p1, FeatureTyping::getTypedFeature); } + @Test + @DisplayName("GIVEN a delegate that can handle a move, WHEN we move an element, THEN the delegate is used") + public void delegateMoveElement() { + Package pack = SysmlFactory.eINSTANCE.createPackage(); + this.resource.getContents().add(pack); + Package p1 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p1, pack); + Package p2 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p2, pack); + CapturingMoveElementServiceDelegate delegate = new CapturingMoveElementServiceDelegate(true, MoveStatus.buildSuccess()); + CapturingDefaultSysMLMoveElementService defaultMoveService = new CapturingDefaultSysMLMoveElementService(MoveStatus.buildFailure("Default implementation should not be used")); + SysMLMoveElementService service = new SysMLMoveElementService(List.of(delegate), defaultMoveService); + + MoveStatus moveStatus = service.moveSemanticElement(p1, p2); + + assertThat(moveStatus.isSuccess()).isTrue(); + assertThat(delegate.moveCallCount).isEqualTo(1); + assertThat(defaultMoveService.moveCallCount).isZero(); + } + + @Test + @DisplayName("GIVEN no delegate that can handle a move, WHEN we move an element, THEN the default implementation is used") + public void defaultMoveElement() { + Package pack = SysmlFactory.eINSTANCE.createPackage(); + this.resource.getContents().add(pack); + Package p1 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p1, pack); + Package p2 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p2, pack); + CapturingMoveElementServiceDelegate delegate = new CapturingMoveElementServiceDelegate(false, MoveStatus.buildFailure("Delegate should not be used")); + CapturingDefaultSysMLMoveElementService defaultMoveService = new CapturingDefaultSysMLMoveElementService(MoveStatus.buildSuccess()); + SysMLMoveElementService service = new SysMLMoveElementService(List.of(delegate), defaultMoveService); + + MoveStatus moveStatus = service.moveSemanticElement(p1, p2); + + assertThat(moveStatus.isSuccess()).isTrue(); + assertThat(delegate.moveCallCount).isZero(); + assertThat(defaultMoveService.moveCallCount).isEqualTo(1); + } + + @Test + @DisplayName("GIVEN a delegate that can handle a move, WHEN the default guard would reject the move, THEN the delegate is still used") + public void delegateIsResponsibleForMoveGuards() { + Package pack = SysmlFactory.eINSTANCE.createPackage(); + this.resource.getContents().add(pack); + Package p1 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p1, pack); + Package p2 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p2, pack); + this.readOnlyElements.add(p1); + CapturingMoveElementServiceDelegate delegate = new CapturingMoveElementServiceDelegate(true, MoveStatus.buildSuccess()); + DefaultSysMLMoveElementService defaultMoveService = this.createDefaultMoveElementService(); + SysMLMoveElementService service = new SysMLMoveElementService(List.of(delegate), defaultMoveService); + + // The default implementation would normally reject that move but since the delegate say it can handle it, it is a success + MoveStatus moveStatus = service.moveSemanticElement(p1, p2); + + assertThat(moveStatus.isSuccess()).isTrue(); + assertThat(delegate.canHandleCallCount).isEqualTo(1); + assertThat(delegate.moveCallCount).isEqualTo(1); + } + + @Test + @DisplayName("GIVEN the default implementation, WHEN canMove rejects the move, THEN the move is not performed") + public void defaultMoveElementUsesCanMove() { + Package pack = SysmlFactory.eINSTANCE.createPackage(); + this.resource.getContents().add(pack); + Package p1 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p1, pack); + Package p2 = SysmlFactory.eINSTANCE.createPackage(); + this.addInPackage(p2, pack); + DefaultSysMLMoveElementService defaultMoveElementService = new DefaultSysMLMoveElementService(new CapturingMoveElementCheckerService(MoveStatus.buildFailure("Forbidden"))); + + MoveStatus moveStatus = defaultMoveElementService.moveSemanticElement(p1, p2); + + assertThat(moveStatus.isSuccess()).isFalse(); + assertThat(pack.getOwnedRelationship()).hasSize(2); + assertThat(p2.getOwnedRelationship()).isEmpty(); + } + private void addInPackage(Element element, Package pack) { OwningMembership owningMembership = SysmlFactory.eINSTANCE.createOwningMembership(); pack.getOwnedRelationship().add(owningMembership); owningMembership.getOwnedRelatedElement().add(element); } + private DefaultSysMLMoveElementService createDefaultMoveElementService() { + return new DefaultSysMLMoveElementService(new SysMLMoveElementCheckerService(e -> this.readOnlyElements.contains(e))); + } + + /** + * Test implementation of {@link IDefaultSysMLMoveElementService} which captures calls. + */ + private static final class CapturingDefaultSysMLMoveElementService implements IDefaultSysMLMoveElementService { + + private final MoveStatus moveStatus; + + private int moveCallCount; + + CapturingDefaultSysMLMoveElementService(MoveStatus moveStatus) { + this.moveStatus = moveStatus; + } + + @Override + public MoveStatus moveSemanticElement(Element element, Element newParent) { + this.moveCallCount++; + return this.moveStatus; + } + } + + /** + * Test implementation of {@link ISysMLMoveElementCheckerService} which returns a fixed status. + */ + private static final class CapturingMoveElementCheckerService implements ISysMLMoveElementCheckerService { + + private final MoveStatus moveStatus; + + CapturingMoveElementCheckerService(MoveStatus moveStatus) { + this.moveStatus = moveStatus; + } + + @Override + public MoveStatus canMove(Element element, Element newParent) { + return this.moveStatus; + } + } + + /** + * Test implementation of {@link ISysMLMoveElementServiceDelegate} which captures calls. + */ + private static final class CapturingMoveElementServiceDelegate implements ISysMLMoveElementServiceDelegate { + + private final boolean canHandle; + + private final MoveStatus moveStatus; + + private int canHandleCallCount; + + private int moveCallCount; + + CapturingMoveElementServiceDelegate(boolean canHandle, MoveStatus moveStatus) { + this.canHandle = canHandle; + this.moveStatus = moveStatus; + } + + @Override + public boolean canHandle(Element element, Element newParent) { + this.canHandleCallCount++; + return this.canHandle; + } + + @Override + public MoveStatus moveSemanticElement(Element element, Element newParent) { + this.moveCallCount++; + return this.moveStatus; + } + } }