diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 49088b0e3..06eb60588 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -26,11 +26,12 @@ - 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_; +- https://github.com/eclipse-syson/syson/issues/2182[#2182] [services] Provide a way for downstream applications to extend _ISysMLMoveElementService_. - https://github.com/eclipse-syson/syson/issues/2240[#2240] [diagrams] Update the choice of _timeslice_ and _snapshot_ elements that can be created in the selection dialog of tools creating _timeslice_ and _snapshot_ elements. Before, the selection dialog option with selection allowed choosing between all _timeslice/snapshot_ types. Now, the choice is restricted to the _timeslice/snapshot_ type that match the graphical node type on which the tool is applied. - +- https://github.com/eclipse-syson/syson/issues/2119[#2119] [details] Display expressions values in the _Details_ view and allow to edit them from there. +- https://github.com/eclipse-syson/syson/issues/2251[#2251] [explorer] Allow expression-related operations on their parent element === New features diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/configuration/SysMLv2PropertiesConfigurer.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/configuration/SysMLv2PropertiesConfigurer.java index ddbf1b316..9c51c98d1 100644 --- a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/configuration/SysMLv2PropertiesConfigurer.java +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/configuration/SysMLv2PropertiesConfigurer.java @@ -79,6 +79,8 @@ @Configuration public class SysMLv2PropertiesConfigurer implements IPropertiesDescriptionRegistryConfigurer { + private static final String CUSTOM_EXPRESSION_WIDGET_KEY = "syson:expression-value-widget"; + private static final String CORE_PROPERTIES = "Core Properties"; private static final String ADVANCED_PROPERTIES = "Advanced Properties"; @@ -189,6 +191,8 @@ private FormDescription createDetailsViewForElement() { pageCore.getGroups().add(this.createExtraAcceptActionUsagePropertiesGroup()); pageCore.getGroups().add(this.createExtraTransitionSourceTargetPropertiesGroup()); pageCore.getGroups().add(this.createFeatureValuePropertiesGroup()); + pageCore.getGroups().add(this.createExpressionPropertiesGroup()); + pageCore.getGroups().add(this.createResultExpressionPropertiesGroup()); PageDescription pageAdvanced = FormFactory.eINSTANCE.createPageDescription(); pageAdvanced.setName("SysON-DetailsView-Advanced"); @@ -203,6 +207,28 @@ private FormDescription createDetailsViewForElement() { return form; } + /** + * Creates a group to display the value of an Expression. + * + * @return a {@link GroupDescription} + */ + private GroupDescription createExpressionPropertiesGroup() { + GroupDescription group = FormFactory.eINSTANCE.createGroupDescription(); + group.setDisplayMode(GroupDisplayMode.LIST); + group.setName("Expression Value"); + group.setLabelExpression(""); + group.setSemanticCandidatesExpression(ServiceMethod.of0(DetailsViewService::getExpression).aqlSelf()); + + TextAreaDescription expressionWidget = FormFactory.eINSTANCE.createTextAreaDescription(); + expressionWidget.setName("Expression"); + expressionWidget.setLabelExpression(CUSTOM_EXPRESSION_WIDGET_KEY); + expressionWidget.setValueExpression(ServiceMethod.of0(DetailsViewService::getExpressionTextualRepresentation).aqlSelf()); + expressionWidget.setIsEnabledExpression(AQLConstants.AQL_FALSE); + + group.getChildren().add(expressionWidget); + + return group; + } /** * Creates a group to display the value of a Feature or FeatureValue. @@ -218,7 +244,7 @@ private GroupDescription createFeatureValuePropertiesGroup() { TextAreaDescription expressionWidget = FormFactory.eINSTANCE.createTextAreaDescription(); expressionWidget.setName("ValueExpression"); - expressionWidget.setLabelExpression("Value"); + expressionWidget.setLabelExpression(CUSTOM_EXPRESSION_WIDGET_KEY); expressionWidget.setValueExpression(ServiceMethod.of0(DetailsViewService::getValueExpressionTextualRepresentation).aqlSelf()); expressionWidget.setIsEnabledExpression(AQLConstants.AQL_FALSE); @@ -227,6 +253,29 @@ private GroupDescription createFeatureValuePropertiesGroup() { return group; } + /** + * Creates a group to display the value of a ResultExpression. + * + * @return a {@link GroupDescription} + */ + private GroupDescription createResultExpressionPropertiesGroup() { + GroupDescription group = FormFactory.eINSTANCE.createGroupDescription(); + group.setDisplayMode(GroupDisplayMode.LIST); + group.setName("Result"); + group.setLabelExpression(""); + group.setSemanticCandidatesExpression(ServiceMethod.of0(DetailsViewService::getResultExpression).aqlSelf()); + + TextAreaDescription expressionWidget = FormFactory.eINSTANCE.createTextAreaDescription(); + expressionWidget.setName("ResultExpression"); + expressionWidget.setLabelExpression(CUSTOM_EXPRESSION_WIDGET_KEY); + expressionWidget.setValueExpression(ServiceMethod.of0(DetailsViewService::getResultExpressionTextualRepresentation).aqlSelf()); + expressionWidget.setIsEnabledExpression(AQLConstants.AQL_FALSE); + + group.getChildren().add(expressionWidget); + + return group; + } + private GroupDescription createCorePropertiesGroup() { GroupDescription group = FormFactory.eINSTANCE.createGroupDescription(); group.setDisplayMode(GroupDisplayMode.LIST); diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/expressions/services/ExpressionTextualRepresentationEventHandler.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/expressions/services/ExpressionTextualRepresentationEventHandler.java index 63ece7f61..ec8fe45fe 100644 --- a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/expressions/services/ExpressionTextualRepresentationEventHandler.java +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/expressions/services/ExpressionTextualRepresentationEventHandler.java @@ -23,6 +23,7 @@ import org.eclipse.sirius.components.core.api.IPayload; import org.eclipse.syson.application.expressions.dto.ExpressionTextualRepresentationInput; import org.eclipse.syson.application.expressions.dto.ExpressionTextualRepresentationPayload; +import org.eclipse.syson.sysml.Element; import org.eclipse.syson.sysml.Expression; import org.eclipse.syson.sysml.metamodel.services.MetamodelQueryElementService; import org.springframework.stereotype.Service; @@ -55,14 +56,38 @@ public boolean canHandle(IEditingContext editingContext, IInput input) { public void handle(Sinks.One payloadSink, Sinks.Many changeDescriptionSink, IEditingContext editingContext, IInput input) { String textualRepresentation = ""; if (input instanceof ExpressionTextualRepresentationInput expressionTextualRepresentationInput) { - Optional optionalExpression = this.objectSearchService.getObject(editingContext, expressionTextualRepresentationInput.elementId()) - .filter(Expression.class::isInstance) - .map(Expression.class::cast) - .filter(this.metamodelQueryElementService::isTopLevelExpression); + String elementId = expressionTextualRepresentationInput.elementId(); + Optional optionalExpression = this.getExpression(editingContext, elementId); if (optionalExpression.isPresent()) { textualRepresentation = this.metamodelQueryElementService.getExpressionTextualRepresentation(optionalExpression.get()); } } payloadSink.tryEmitValue(new ExpressionTextualRepresentationPayload(input.id(), textualRepresentation)); } + + /** + * Finds the {@link Expression} element to consider given the provided {@code elementId}. + * + * @param editingContext + * the editing context. + * @param elementId + * either to id of an actual {@link Expression} element, or of the parent {@link Element} of a single + * {@code Expression}. + * @return the directly of indirectly designated {@link Expression}. + */ + private Optional getExpression(IEditingContext editingContext, String elementId) { + Optional result = Optional.empty(); + Optional optionalElement = this.objectSearchService.getObject(editingContext, elementId) + .filter(Element.class::isInstance) + .map(Element.class::cast); + if (optionalElement.isPresent()) { + Element element = optionalElement.get(); + if (element instanceof Expression expression && this.metamodelQueryElementService.isTopLevelExpression(expression)) { + result = optionalElement.map(Expression.class::cast); + } else { + result = this.metamodelQueryElementService.findSingleExpressionDefinition(element); + } + } + return result; + } } diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/services/DetailsViewService.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/services/DetailsViewService.java index def9195d8..a7c338ce1 100644 --- a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/services/DetailsViewService.java +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/services/DetailsViewService.java @@ -44,6 +44,7 @@ import org.eclipse.syson.sysml.Annotation; import org.eclipse.syson.sysml.Comment; import org.eclipse.syson.sysml.ConjugatedPortDefinition; +import org.eclipse.syson.sysml.Definition; import org.eclipse.syson.sysml.Documentation; import org.eclipse.syson.sysml.Element; import org.eclipse.syson.sysml.EndFeatureMembership; @@ -55,6 +56,7 @@ import org.eclipse.syson.sysml.FeatureValue; import org.eclipse.syson.sysml.Import; import org.eclipse.syson.sysml.Membership; +import org.eclipse.syson.sysml.Namespace; import org.eclipse.syson.sysml.ParameterMembership; import org.eclipse.syson.sysml.ReferenceSubsetting; import org.eclipse.syson.sysml.ReferenceUsage; @@ -68,6 +70,7 @@ import org.eclipse.syson.sysml.SysmlPackage; import org.eclipse.syson.sysml.TransitionUsage; import org.eclipse.syson.sysml.Type; +import org.eclipse.syson.sysml.Usage; import org.eclipse.syson.sysml.ViewUsage; import org.eclipse.syson.sysml.metamodel.services.ElementInitializerSwitch; import org.eclipse.syson.sysml.metamodel.services.MetamodelQueryElementService; @@ -570,12 +573,16 @@ public Element setNewDocumentationValue(Element self, String newValue) { * a {@link FeatureValue} or {@link Feature} * @return a {@link FeatureValue} or null */ - public Element getFeatureValue(Element self) { - Element result = null; + public FeatureValue getFeatureValue(Element self) { + FeatureValue result = null; if (self instanceof FeatureValue featureValue && featureValue.getValue() != null) { result = featureValue; } else if (self instanceof Feature feature) { - result = this.metamodelQueryElementService.getValueExpression(feature).orElse(null); + result = feature.getOwnedRelationship().stream() + .filter(FeatureValue.class::isInstance) + .map(FeatureValue.class::cast) + .findFirst() + .orElse(null); } return result; } @@ -626,6 +633,39 @@ private String getExpressionAsText(Expression expression) { return this.metamodelQueryElementService.getExpressionTextualRepresentation(expression); } + /** + * Gets the {@link ResultExpressionMembership} from a {@link Namespace} or a {@link ResultExpressionMembership}. + * + * @param self + * a {@link Namespace} or a {@link ResultExpressionMembership}. + * @return a {@link ResultExpressionMembership} or null + */ + public Element getResultExpression(Element self) { + Element result = null; + if (self instanceof ResultExpressionMembership expressionMembership && expressionMembership.getOwnedResultExpression() != null) { + result = expressionMembership; + } else if (self instanceof Namespace namespace && this.metamodelQueryElementService.getResultExpressionMembership(namespace) != null + && this.metamodelQueryElementService.getResultExpressionMembership(namespace).getOwnedResultExpression() != null) { + result = this.metamodelQueryElementService.getResultExpressionMembership(namespace); + } + return result; + } + + /** + * Gets the {@link ResultExpressionMembership} from a {@link Namespace} or a {@link ResultExpressionMembership}. + * + * @param self + * a {@link Namespace} or a {@link ResultExpressionMembership}. + * @return a {@link ResultExpressionMembership} or null + */ + public Element getExpression(Element self) { + if (self instanceof Expression && !(self instanceof Usage) && !(self instanceof Definition)) { + return self; + } else { + return null; + } + } + /** * Returns the element that owns the visibility feature of the given element. * diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/expressions/ExpressionsControllersIntegrationTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/expressions/ExpressionsControllersIntegrationTests.java index a63ae0383..c1441dbd6 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/expressions/ExpressionsControllersIntegrationTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/expressions/ExpressionsControllersIntegrationTests.java @@ -553,18 +553,21 @@ public void topLevelExpressionTextualRepresentation() { treeId.set(tree.getId()); }); - // The Tank part and its attribute are not themselves expressions => "" + // The Tank part is not an expression => "" var checkTank = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.TANK_ID, ""); - var checkTankAttribute = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.TANK_MAX_VOLUME_ATTRIBUTE_ID, ""); + // The Tank's attribute is not an expression itself either, but it owns a single, non-ambiguous expression to + // can act as a "shortcut" to interact with id. + var checkTankAttribute = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.TANK_MAX_VOLUME_ATTRIBUTE_ID, "100.0 * minVolume"); // The actual attribute default value expression however should be correctly represented var checkTankAttributeValueExpression = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.TANK_MAX_VOLUME_ATTRIBUTE_VALUE_ID, "100.0 * minVolume"); var checkPerformanceConcern = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.PERFORMANCE_CONCERN_ID, ""); // A ConstaintUsage *is* an Expression from the point of view of SysML's type hierarchy, but not a top-level - // Expression, so we expect "" here. + // Expression. However if it contains a single expression, it an act as a "shortcut" to it. var checkPerformanceConcernAssumeConstraint = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.PERFORMANCE_CONCERN_ASSUME_ID, ""); - var checkPerformanceConcernRequireConstraint = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.PERFORMANCE_CONCERN_REQUIRE_ID, ""); + var checkPerformanceConcernRequireConstraint = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.PERFORMANCE_CONCERN_REQUIRE_ID, + "s.samplingRate >= 50.0 & s.currentValue != 0.0 | s.errorCount == 0"); // require s.samplingRate >= 50.0 & s.currentValue != 0.0 | s.errorCount == 0 var checkPerformanceConcernRequireConstraintExpression = this.checkExpressiontTextualRepresentation(editingContextId, ExpressionSamplesProjectData.SemanticIds.PERFORMANCE_CONCERN_REQUIRE_EXPRESSION_ID, "s.samplingRate >= 50.0 & s.currentValue != 0.0 | s.errorCount == 0"); diff --git a/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/dto/EditExpressionInput.java b/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/dto/EditExpressionInput.java index dac6921c0..ca0d50bda 100644 --- a/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/dto/EditExpressionInput.java +++ b/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/dto/EditExpressionInput.java @@ -21,5 +21,5 @@ * * @author pcdavid */ -public record EditExpressionInput(UUID id, String editingContextId, String expressionElementId, String newExpressionText) implements IInput { +public record EditExpressionInput(UUID id, String editingContextId, String elementId, String newExpressionText) implements IInput { } diff --git a/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/services/EditExpressionEventHandler.java b/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/services/EditExpressionEventHandler.java index 02d6077ab..2c11c5214 100644 --- a/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/services/EditExpressionEventHandler.java +++ b/backend/application/syson-sysml-import/src/main/java/org/eclipse/syson/sysml/services/EditExpressionEventHandler.java @@ -31,6 +31,7 @@ import org.eclipse.syson.sysml.Expression; import org.eclipse.syson.sysml.dto.EditExpressionInput; import org.eclipse.syson.sysml.dto.EditExpressionSuccessPayload; +import org.eclipse.syson.sysml.metamodel.services.MetamodelQueryElementService; import org.eclipse.syson.sysml.services.api.ISysMLExpressionEditor; import org.springframework.stereotype.Service; @@ -54,6 +55,8 @@ public class EditExpressionEventHandler implements IEditingContextEventHandler { private final ISysMLExpressionEditor expressionEditor; + private final MetamodelQueryElementService metamodelQueryElementService; + private final Counter counter; public EditExpressionEventHandler(IObjectSearchService objectSearchService, IIdentityService identityService, ICollaborativeMessageService messageService, ISysMLExpressionEditor expressionEditor, @@ -62,6 +65,7 @@ public EditExpressionEventHandler(IObjectSearchService objectSearchService, IIde this.identityService = Objects.requireNonNull(identityService); this.messageService = Objects.requireNonNull(messageService); this.expressionEditor = Objects.requireNonNull(expressionEditor); + this.metamodelQueryElementService = new MetamodelQueryElementService(); this.counter = Counter.builder(Monitoring.EVENT_HANDLER) .tag(Monitoring.NAME, this.getClass().getSimpleName()) .register(meterRegistry); @@ -79,13 +83,8 @@ public void handle(Sinks.One payloadSink, Sinks.Many optionalExpression = this.objectSearchService.getObject(editingContext, editExpressionInput.expressionElementId()) - .filter(Expression.class::isInstance) - .map(Expression.class::cast); - Optional optionalParent = Optional.empty(); - if (optionalExpression.isPresent()) { - optionalParent = Optional.ofNullable(optionalExpression.get().getOwner()); - } + var optionalParent = this.getExpressionParent(emfEditingContext, editExpressionInput.elementId()); + var optionalExpression = this.getExpression(emfEditingContext, editExpressionInput.elementId()); if (optionalParent.isPresent() && optionalExpression.isPresent()) { var result = this.expressionEditor.editExpression(emfEditingContext, optionalParent.get(), optionalExpression.get(), editExpressionInput.newExpressionText()); @@ -108,4 +107,57 @@ public void handle(Sinks.One payloadSink, Sinks.Many getExpression(IEditingContext editingContext, String elementId) { + Optional result = Optional.empty(); + Optional optionalElement = this.objectSearchService.getObject(editingContext, elementId) + .filter(Element.class::isInstance) + .map(Element.class::cast); + if (optionalElement.isPresent()) { + Element element = optionalElement.get(); + if (element instanceof Expression expression && this.metamodelQueryElementService.isTopLevelExpression(expression)) { + result = optionalElement.map(Expression.class::cast); + } else { + result = this.metamodelQueryElementService.findSingleExpressionDefinition(element); + } + } + return result; + } + + /** + * Finds the {@link Expression} element to consider given the provided {@code elementId}. + * + * @param editingContext + * the editing context. + * @param elementId + * either to id of an actual {@link Expression} element, or of the parent {@link Element} of a single + * {@code Expression}. + * @return the directly of indirectly designated {@link Expression}. + */ + private Optional getExpressionParent(IEditingContext editingContext, String elementId) { + Optional result = Optional.empty(); + + Optional optionalElement = this.objectSearchService.getObject(editingContext, elementId) + .filter(Element.class::isInstance) + .map(Element.class::cast); + if (optionalElement.isPresent()) { + Element element = optionalElement.get(); + if (element instanceof Expression expression && this.metamodelQueryElementService.isTopLevelExpression(expression)) { + result = optionalElement.map(Element::getOwner); + } else { + result = optionalElement; + } + } + return result; + } } diff --git a/backend/application/syson-sysml-import/src/main/resources/schema/syson-import.graphqls b/backend/application/syson-sysml-import/src/main/resources/schema/syson-import.graphqls index a2df45eca..3eb3b4ada 100644 --- a/backend/application/syson-sysml-import/src/main/resources/schema/syson-import.graphqls +++ b/backend/application/syson-sysml-import/src/main/resources/schema/syson-import.graphqls @@ -34,7 +34,7 @@ type CreateExpressionSuccessPayload { input EditExpressionInput { id: ID! editingContextId: ID! - expressionElementId: ID! + elementId: ID! newExpressionText: String! } diff --git a/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelQueryElementService.java b/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelQueryElementService.java index 54b3fb429..373bf1965 100644 --- a/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelQueryElementService.java +++ b/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelQueryElementService.java @@ -27,9 +27,11 @@ import org.eclipse.syson.sysml.Feature; import org.eclipse.syson.sysml.FeatureValue; import org.eclipse.syson.sysml.FramedConcernMembership; +import org.eclipse.syson.sysml.Namespace; import org.eclipse.syson.sysml.OwningMembership; import org.eclipse.syson.sysml.PartUsage; import org.eclipse.syson.sysml.ReferenceUsage; +import org.eclipse.syson.sysml.ResultExpressionMembership; import org.eclipse.syson.sysml.StakeholderMembership; import org.eclipse.syson.sysml.SubjectMembership; import org.eclipse.syson.sysml.SysmlFactory; @@ -306,4 +308,20 @@ public ConcernUsage getFramedConcernTarget(FramedConcernMembership framedConcern } return null; } + + /** + * Get the {@link ResultExpressionMembership} contained inside a given {@link Namespace}. + * + * @param namespace + * a given {@link Namespace}. + * @return a {@link ResultExpressionMembership}, or null if not found. + */ + public ResultExpressionMembership getResultExpressionMembership(Namespace namespace) { + return namespace.getOwnedMembership().stream() + .filter(ResultExpressionMembership.class::isInstance) + .map(ResultExpressionMembership.class::cast) + .findFirst() + .orElse(null); + } + } diff --git a/backend/views/syson-tree-explorer-view/src/main/java/org/eclipse/syson/tree/explorer/view/menu/context/SysONExplorerTreeItemContextMenuEntryProvider.java b/backend/views/syson-tree-explorer-view/src/main/java/org/eclipse/syson/tree/explorer/view/menu/context/SysONExplorerTreeItemContextMenuEntryProvider.java index 9e4dee378..65ef1f374 100644 --- a/backend/views/syson-tree-explorer-view/src/main/java/org/eclipse/syson/tree/explorer/view/menu/context/SysONExplorerTreeItemContextMenuEntryProvider.java +++ b/backend/views/syson-tree-explorer-view/src/main/java/org/eclipse/syson/tree/explorer/view/menu/context/SysONExplorerTreeItemContextMenuEntryProvider.java @@ -192,7 +192,8 @@ private void addExpressionEditionEntries(List entries expressionEntries.add(EDIT_EXPRESSION_MENU_ENTRY_CONTRIBUTION_ID); } else if (this.metamodelQueryElementService.hasSingleExpressionDefinition(element) && !this.metamodelQueryElementService.hasSingleExpressionDefinition(element.getOwner())) { - // "Delete expression" on the owner of a root Expression element + // "Edit expression" and "Delete expression" on the owner of a root Expression element + expressionEntries.add(EDIT_EXPRESSION_MENU_ENTRY_CONTRIBUTION_ID); expressionEntries.add(DELETE_EXPRESSION_MENU_ENTRY_CONTRIBUTION_ID); } diff --git a/doc/content/modules/user-manual/assets/images/expression-details.png b/doc/content/modules/user-manual/assets/images/expression-details.png new file mode 100644 index 000000000..b7ba2f237 Binary files /dev/null and b/doc/content/modules/user-manual/assets/images/expression-details.png differ diff --git a/doc/content/modules/user-manual/pages/features/expressions.adoc b/doc/content/modules/user-manual/pages/features/expressions.adoc index dd516a555..b4cb3b750 100644 --- a/doc/content/modules/user-manual/pages/features/expressions.adoc +++ b/doc/content/modules/user-manual/pages/features/expressions.adoc @@ -19,6 +19,10 @@ a|image::explorer-expression-internals-hidden.png[Internals hidden (default)] a|image::explorer-expression-internals-visible.png[Internals visible] |=== +When an `Expression` element or an `Element` which contains a single `Expression` element is selected (for example from the {explorer} view), the {details}} view displays the textual representation of the expression, with a button to open the expression edition modal (see below) directly on it: ++ +image::expression-details.png[Expression value displayed in the _Details_ veiw, width=80%] + == Creation To _create_ an expression, invoke the _New expression_ context menu action on a compatible element (supported elements are `Attributes`, `Constraints` and `Transitions`). @@ -35,10 +39,10 @@ image::edit-expression-modal-error.png[Edit expression modal showing an error me == Edition -To edit an already existing expression, invoke the _Edit expression_ context menu action directly on the existing expression. +To edit an already existing expression, invoke the _Edit expression_ context menu action directly on the existing expression or on its parent element. The same modal as for expression creation will open, but with the current textual representation of the expression pre-filled. -Edit the textual representation of the expression as required, and click on the _Update_ button to validate. +Edit the textual representation of the expression as required, and click on the _Update_ button or hit _Ctrl-RET_ to validate. As for expression creation, the change will only be applied if there are no errors detected; otherwise the modal will display any errors (e.g. names used in the expression that can not be resolve to existing elements) so that you can fix the input. diff --git a/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc b/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc index d3b23905f..f6fec0724 100644 --- a/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc +++ b/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc @@ -51,7 +51,7 @@ In both cases, to display the `FramedConcernMembership`, deactivate the `Hide Me ** It is now possible to create, edit and delete _Expressions_ using their textual syntax from the _Explorer_ view. To _create_ an expression, invoke the _New expression_ context menu action on a compatible element (supported elements are `Attributes`, `Constraints` and `Transitions`). A modal will open where you can enter the textual representation of the expression to create. -Click on the _Update_ button to validate. +Click on the _Update_ button or hit _Ctrl-RET_ to validate. If the entered expression is valid, it will be created and the modal will automatically close. If the entered expression is _not_ valid, the modal will remain open and show the error(s) in an expandable accordion. Fix any error reported before clicking on _Update_ again (or canceling the operation). @@ -59,10 +59,15 @@ Fix any error reported before clicking on _Update_ again (or canceling the opera image::edit-expression-modal.png[Edit expression modal, width=80%] image::edit-expression-modal-error.png[Edit expression modal showing an error message if the new expression is invalid, width=80%] + -To _edit_ an existing expression, simply invoke the _Edit expression_ context menu action directly on the existing expression; the same modal will open but with the current textual representation of the expression pre-filled. +To _edit_ an existing expression, simply invoke the _Edit expression_ context menu action directly on the existing expression or on its parent element; the same modal will open but with the current textual representation of the expression pre-filled. + To _delete_ an existing expression, you can simply invoke the normal _Delete_ menu item action on the expression itself or the new _Delete expression_ on its parent element. +* In the _Details_ view: + +** When an `Expression` element or an `Element` which contains a single `Expression` element is selected (for example from the _Explorer_ view), the _Details_ view displays the textual representation of the `Expression`, with a button to open the expression edition modal (see above) directly on it: ++ +image::expression-details.png[Expression value displayed in the _Details_ veiw, width=80%] == Bug fixes diff --git a/frontend/syson-components/src/extensions/SysONExtensionRegistryMergeStrategy.ts b/frontend/syson-components/src/extensions/SysONExtensionRegistryMergeStrategy.ts index f2dfab167..97fe7e724 100644 --- a/frontend/syson-components/src/extensions/SysONExtensionRegistryMergeStrategy.ts +++ b/frontend/syson-components/src/extensions/SysONExtensionRegistryMergeStrategy.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 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,6 +12,7 @@ *******************************************************************************/ import { DataExtension, ExtensionRegistryMergeStrategy } from '@eclipse-sirius/sirius-components-core'; +import { widgetContributionExtensionPoint } from '@eclipse-sirius/sirius-components-forms'; import { omniboxCommandOverrideContributionExtensionPoint } from '@eclipse-sirius/sirius-components-omnibox'; import { treeItemContextMenuEntryOverrideExtensionPoint } from '@eclipse-sirius/sirius-components-trees'; import { @@ -37,6 +38,9 @@ export class SysONExtensionRegistryMergeStrategy if (identifier === treeItemContextMenuEntryOverrideExtensionPoint.identifier) { return this.mergeTreeItemContributions(existingValues, newValues); } + if (identifier === widgetContributionExtensionPoint.identifier) { + return this.mergeWidgetContributions(existingValues, newValues); + } return newValues; } @@ -69,4 +73,14 @@ export class SysONExtensionRegistryMergeStrategy data: [...existingContributions.data, ...newContributions.data], }; } + + private mergeWidgetContributions( + existingContributions: DataExtension, + newContributions: DataExtension + ): DataExtension { + return { + identifier: `syson_${widgetContributionExtensionPoint.identifier}`, + data: [...existingContributions.data, ...newContributions.data], + }; + } } diff --git a/frontend/syson-components/src/extensions/expressions/EditSysMLExpressionModal.tsx b/frontend/syson-components/src/extensions/expressions/EditSysMLExpressionModal.tsx index 544e1454d..b176008da 100644 --- a/frontend/syson-components/src/extensions/expressions/EditSysMLExpressionModal.tsx +++ b/frontend/syson-components/src/extensions/expressions/EditSysMLExpressionModal.tsx @@ -28,7 +28,7 @@ import InputAdornment from '@mui/material/InputAdornment'; import { Theme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { makeStyles } from 'tss-react/mui'; import { EditSysMLExpressionModalProps, EditSysMLExpressionModalState } from './EditSysMLExpressionModal.types'; import { useCreateExpression } from './useCreateExpression'; @@ -96,6 +96,8 @@ export const EditSysMLExpressionModal = ({ const validationStatus = computeValidationStatus(state.validationResult); const busy = state.operationInProgress !== null; + const inputRef = useRef(null); + const { textualRepresentation, loading } = useExpressionTextualRepresentation(editingContextId, elementId); useEffect(() => { if (loading) { @@ -110,12 +112,21 @@ export const EditSysMLExpressionModal = ({ } }, [textualRepresentation, loading]); + const fieldReady = !busy && state.textualContent !== null; + useEffect(() => { + if (fieldReady) { + const timeoutId = window.setTimeout(() => inputRef.current?.focus()); + return () => window.clearTimeout(timeoutId); + } + return undefined; + }, [fieldReady]); + const { createExpression, loading: creationInProgress, messages: postCreationMessages } = useCreateExpression(); const { editExpression, loading: editionInProgress, messages: postEditionMessages } = useEditExpression(); const { deleteExpression } = useDeleteExpression(); - const onUpdate = (event: React.MouseEvent) => { - event.preventDefault(); + const onUpdate = (event: React.MouseEvent | null) => { + event?.preventDefault(); if (state.textualContent !== null) { if (mode === 'create' && state.textualContent.trim() !== '') { setState((prevState) => ({ ...prevState, operationInProgress: 'creating' })); @@ -169,6 +180,7 @@ export const EditSysMLExpressionModal = ({ id="edit-sysml-expression-modal-textarea" data-testid="edit-sysml-expression-modal-textarea" disabled={busy || state.textualContent === null} + inputRef={inputRef} className={classes.textarea} autoFocus multiline @@ -180,6 +192,11 @@ export const EditSysMLExpressionModal = ({ onChange={(event) => setState((prevState) => ({ ...prevState, textualContent: event.target.value, validationResult: null })) } + onKeyUp={(event) => { + if (event.ctrlKey && event.key === 'Enter') { + onUpdate(null); + } + }} slotProps={{ input: { endAdornment: , diff --git a/frontend/syson-components/src/extensions/expressions/ExpressionPropertySection.tsx b/frontend/syson-components/src/extensions/expressions/ExpressionPropertySection.tsx new file mode 100644 index 000000000..d45041e5f --- /dev/null +++ b/frontend/syson-components/src/extensions/expressions/ExpressionPropertySection.tsx @@ -0,0 +1,187 @@ +/******************************************************************************* + * 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 + *******************************************************************************/ + +import { getCSSColor } from '@eclipse-sirius/sirius-components-core'; +import { + GQLTextarea, + GQLTextfield, + PropertySectionComponent, + PropertySectionComponentProps, + PropertySectionLabel, + TextfieldStyleProps, +} from '@eclipse-sirius/sirius-components-forms'; + +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import { useState } from 'react'; +import { makeStyles } from 'tss-react/mui'; +import { EditSysMLExpressionModal } from './EditSysMLExpressionModal'; + +const useStyle = makeStyles()( + (theme, { backgroundColor, foregroundColor, fontSize, italic, bold, gridLayout }) => { + const { + gridTemplateColumns, + gridTemplateRows, + labelGridColumn, + labelGridRow, + widgetGridColumn, + widgetGridRow, + gap, + } = { + ...gridLayout, + }; + return { + style: { + backgroundColor: backgroundColor ? getCSSColor(backgroundColor, theme) : undefined, + color: foregroundColor ? getCSSColor(foregroundColor, theme) : undefined, + fontSize: fontSize ? fontSize : undefined, + fontStyle: italic ? 'italic' : undefined, + fontWeight: bold ? 'bold' : undefined, + }, + input: { + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + }, + textfield: { + marginTop: theme.spacing(0.5), + marginBottom: theme.spacing(0.5), + }, + formControl: {}, + propertySection: { + display: 'grid', + gridTemplateColumns, + gridTemplateRows, + alignItems: 'center', + gap: gap ?? '', + }, + propertySectionLabel: { + gridColumn: labelGridColumn, + gridRow: labelGridRow, + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(2), + alignItems: 'center', + }, + propertySectionWidget: { + gridColumn: widgetGridColumn, + gridRow: widgetGridRow, + }, + }; + } +); + +// Extracts the UUID from the string of the form "details://?objectIds=[c5f78f3a-8b39-4cb0-903a-cedd8e6e71f6]" if it contains a single UUID, otherwise returns null. +const extractObjectIdFromDetailsString = (detailsString: string): string | null => { + const regex = /objectIds=\[([0-9a-fA-F-]{36})\]/; + const match = detailsString.match(regex); + return match && match[1] ? match[1] : null; +}; + +type ExpressionPropertySectionState = { + state: 'idle' | 'modal'; +}; + +export const ExpressionPropertySection: PropertySectionComponent = ({ + editingContextId, + formId, + widget, +}: PropertySectionComponentProps) => { + const props: TextfieldStyleProps = { + backgroundColor: widget.style?.backgroundColor ?? null, + foregroundColor: widget.style?.foregroundColor ?? null, + fontSize: widget.style?.fontSize ?? null, + italic: widget.style?.italic ?? null, + bold: widget.style?.bold ?? null, + underline: widget.style?.underline ?? null, + strikeThrough: widget.style?.strikeThrough ?? null, + gridLayout: widget.style?.widgetGridLayout ?? null, + }; + const { classes } = useStyle(props); + const [state, setState] = useState({ + state: 'idle', + }); + const onCloseModal = () => { + setState((prevState) => ({ ...prevState, state: 'idle' })); + }; + const onEditExpression = () => { + setState((prevState) => ({ ...prevState, state: 'modal' })); + }; + + const targetObjectId = extractObjectIdFromDetailsString(formId); + + let modalElement: JSX.Element | null = null; + if (state.state === 'modal' && targetObjectId !== null) { + modalElement = ( + + ); + } + + const labelOverride = 'Expression value'; + const widgetForLabel = { ...widget, label: labelOverride }; + return ( +
+
+ +
+ 0} + helperText={widget.diagnostics[0]?.message} + className={classes.textfield} + InputProps={ + widget.style + ? { + className: classes.style, + } + : {} + } + inputProps={{ + 'data-testid': `input-${labelOverride}`, + className: classes.input, + }} + slotProps={{ + input: { + endAdornment: ( + + + + + + + + ), + }, + }} + /> + {modalElement} +
+ ); +}; diff --git a/frontend/syson-components/src/extensions/expressions/useEditExpression.ts b/frontend/syson-components/src/extensions/expressions/useEditExpression.ts index 32d72890b..6cc80e48b 100644 --- a/frontend/syson-components/src/extensions/expressions/useEditExpression.ts +++ b/frontend/syson-components/src/extensions/expressions/useEditExpression.ts @@ -52,11 +52,11 @@ export const useEditExpression = (): UseEditExpressionValue => { GQLEditExpressionVariables >(editExpressionMutation); - const editExpression = (editingContextId: string, expressionElementId: string, newExpressionText: string) => { + const editExpression = (editingContextId: string, elementId: string, newExpressionText: string) => { const input: GQLEditExpressionInput = { id: crypto.randomUUID(), editingContextId, - expressionElementId, + elementId: elementId, newExpressionText, }; diff --git a/frontend/syson-components/src/extensions/expressions/useEditExpression.types.ts b/frontend/syson-components/src/extensions/expressions/useEditExpression.types.ts index 448b57a2d..a7fbd4be3 100644 --- a/frontend/syson-components/src/extensions/expressions/useEditExpression.types.ts +++ b/frontend/syson-components/src/extensions/expressions/useEditExpression.types.ts @@ -27,7 +27,7 @@ export interface GQLEditExpressionVariables { export interface GQLEditExpressionInput { id: string; editingContextId: string; - expressionElementId: string; + elementId: string; newExpressionText: string; } diff --git a/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.ts b/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx similarity index 89% rename from frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.ts rename to frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx index 9f6e48a70..4d8468138 100644 --- a/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.ts +++ b/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx @@ -10,6 +10,7 @@ * Contributors: * Obeo - initial API and implementation *******************************************************************************/ + import { ExtensionRegistry } from '@eclipse-sirius/sirius-components-core'; import { diagramToolbarActionExtensionPoint, @@ -21,6 +22,11 @@ import { paletteAppearanceSectionExtensionPoint, RectangularNodeAppearanceSection, } from '@eclipse-sirius/sirius-components-diagrams'; +import { + GQLWidget, + PropertySectionComponent, + widgetContributionExtensionPoint, +} from '@eclipse-sirius/sirius-components-forms'; import { OmniboxCommand, OmniboxCommandOverrideContribution, @@ -37,6 +43,7 @@ import { ImportLibraryCommand, navigationBarMenuIconExtensionPoint, } from '@eclipse-sirius/sirius-web-application'; +import QuestionMarkOutlinedIcon from '@mui/icons-material/QuestionMarkOutlined'; import { Edge, Node, useStoreApi } from '@xyflow/react'; import { SysMLImportedPackageNodePaletteAppearanceSection } from '../../nodes/imported_package/SysMLImportedPackageNodePaletteAppearanceSection'; import { SysMLNoteNodePaletteAppearanceSection } from '../../nodes/note/SysMLNoteNodePaletteAppearanceSection'; @@ -45,6 +52,7 @@ import { sysMLNodesStyleDocumentTransform } from '../../nodes/SysMLNodesDocument import { SysMLViewFrameNodePaletteAppearanceSection } from '../../nodes/view_frame/SysMLViewFrameNodePaletteAppearanceSection'; import { DeleteSysMLExpressionMenuContribution } from '../expressions/DeleteSysMLExpressionMenuContribution'; import { EditSysMLExpressionMenuContribution } from '../expressions/EditSysMLExpressionMenuContribution'; +import { ExpressionPropertySection } from '../expressions/ExpressionPropertySection'; import { NewSysMLExpressionMenuContribution } from '../expressions/NewSysMLExpressionMenuContribution'; import { InsertTextualSysMLMenuContribution } from '../InsertTextualSysMLv2MenuContribution'; import { SysONNavigationBarMenuIcon } from '../navigationBarMenu/SysONNavigationBarMenuIcon'; @@ -215,4 +223,22 @@ sysONExtensionRegistry.putData(pale data: customNodePaletteAppearanceSectionContribution, }); +sysONExtensionRegistry.putData(widgetContributionExtensionPoint, { + identifier: `syson_${widgetContributionExtensionPoint.identifier}`, + data: [ + { + name: 'ExpressionValuePropertySectionOverride', + icon: , + previewComponent: () => null, + component: (widget: GQLWidget): PropertySectionComponent | null => { + let propertySectionComponent: PropertySectionComponent | null = null; + if (widget.__typename == 'Textarea' && widget.label.startsWith('syson:expression-value-widget')) { + propertySectionComponent = ExpressionPropertySection as PropertySectionComponent; + } + return propertySectionComponent; + }, + }, + ], +}); + export { sysONExtensionRegistry }; diff --git a/integration-tests-cypress/cypress/e2e/project/details/details.cy.ts b/integration-tests-cypress/cypress/e2e/project/details/details.cy.ts index e9d0b3eaf..842c340f7 100644 --- a/integration-tests-cypress/cypress/e2e/project/details/details.cy.ts +++ b/integration-tests-cypress/cypress/e2e/project/details/details.cy.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 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 @@ -85,5 +85,16 @@ describe('Details View Tests', () => { details.getReferenceWidgetSelectedValue('Specializes', 'Power').should('exist'); }); }); + context('When we select an Expression', () => { + beforeEach(() => { + explorer.select('VehicleMaxSpeed'); + explorer.expandAll('VehicleMaxSpeed'); + explorer.select('vehicle.actualSpeed <= maxSpeed'); + }); + it("Then the Details view shows the expression's textual value", () => { + details.getTextField('Expression value').should('exist'); + details.getTextField('Expression value').should('have.value', 'vehicle.actualSpeed <= maxSpeed'); + }); + }); }); }); diff --git a/integration-tests-cypress/cypress/workbench/Explorer.ts b/integration-tests-cypress/cypress/workbench/Explorer.ts index 4963437a6..291ce4183 100644 --- a/integration-tests-cypress/cypress/workbench/Explorer.ts +++ b/integration-tests-cypress/cypress/workbench/Explorer.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 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 @@ -70,6 +70,11 @@ export class Explorer { cy.getByTestId('treeitem-contextmenu').findByTestId('delete').click(); } + public expandAll(treeItemLabel: string): void { + this.getTreeItemByLabel(treeItemLabel).find('button').click(); + cy.getByTestId('treeitem-contextmenu').findByTestId('expand-all').click(); + } + public dragTreeItem(treeItemLabel: string, dataTransfer: DataTransfer): void { this.getTreeItemByLabel(treeItemLabel).trigger('dragstart', { dataTransfer }); }