Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<DiagramRefreshedEventPayload> 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> diagram = new AtomicReference<>();

Consumer<Object> 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<Object> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand All @@ -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";

}
}
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* 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.
* </p>
*
* @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;
}
}
Loading
Loading