Skip to content

Commit

Permalink
[2236] Add support for dynamic widgets (For & If) in Forms
Browse files Browse the repository at this point in the history
Bug: #2236
Signed-off-by: Pierre-Charles David <pierre-charles.david@obeo.fr>
  • Loading branch information
pcdavid committed Sep 1, 2023
1 parent 58ec156 commit 010ab5f
Show file tree
Hide file tree
Showing 178 changed files with 4,947 additions and 1,501 deletions.
13 changes: 12 additions & 1 deletion CHANGELOG.adoc
Expand Up @@ -7,7 +7,7 @@
=== Architectural decision records

- [ADR-107] Add support for dynamic and conditional widgets in Forms

- [ADR-108] Improve the widget id computation

=== Breaking changes

Expand All @@ -29,6 +29,12 @@ All of its remaining content was only relevant inside of Sirius Web and it has t
- [releng] Stop minifying the code of Sirius Components packages.
As a result the development of applications consuming Sirius Components packages will be easier and it will be the responsability of the application to minify everything (which they should already be doing).
- https://github.com/eclipse-sirius/sirius-web/issues/2296[#2296] [diagram] `sirius-components-diagrams-reactflow` now depends on `html-to-image` node package.
- https://github.com/eclipse-sirius/sirius-web/issues/2236[#2236] [forms] The introduction of _ForDescription_ and _IfDescription_ elements to the Forms DSL introduced breaking changes in the metamodel.
Existing FormDescription documents will need to be modified to load correctly, and any custom widget's metamodel will need to be regenerated using the new `form.ecore` version as reference.
See [ADR-107] _Add support for dynamic and conditional widgets in Forms_ for the details of the changes.
Also, all widget descriptions now have a `Function<VariableManager, String> targetObjectIdProvider`, needed to compute the widget instance's id.
The new `targetObjectIdProvider` is required to be non-null, so any code which programmatically builds core Form Descriptions (not View-based ones), needs to pass a non-null provider.
A suitable implementation is to get the id of the `self` object using `IObjectService.getId()`.

=== Dependency update

Expand All @@ -55,6 +61,11 @@ As a result the development of applications consuming Sirius Components packages

- https://github.com/eclipse-sirius/sirius-web/issues/2185[#2185] [diagram] Add support for drop tool from the explorer with React Flow.
- https://github.com/eclipse-sirius/sirius-web/issues/2117[#2117] [form] Add a modal to select new values for multi-valued references
- https://github.com/eclipse-sirius/sirius-web/issues/2236[#2236] [forms] It is now possible to use `For` and `If` constructs inside the definition of Form (currently only inside _Groups_ and _Flexbox containers_).
Both constructs are commonly combined together, with e.g. _For_ iterating on the different features of the current semantic element and several _If_ inside the loop to decide which concrete widget(s) to instantiate depending on the feature's type.
They can however also be used independently (e.g. a single _If_ ouside of a _For_ to make the presence of a widget conditional) and combined in arbitrary ways (e.g. nested loops or conditionals).
Note that this first version has a known limitation: _FormDescriptions_ which use _For_ or _If_ elements can not be reliably edited using the _Form Description Editor_.
This will be fixed in the next version.

=== Improvements

Expand Down
35 changes: 18 additions & 17 deletions doc/adrs/107_dynamic_widgets.adoc
@@ -1,4 +1,4 @@
= [ADR-107] Add support for dynamic and conditional widgets in Forms
= ADR-107 - Add support for dynamic and conditional widgets in Forms

== Context

Expand All @@ -11,29 +11,32 @@ The core rendering algorithm of the Sirius Web Forms already implements these me

== Decision

We will add support for `DynamicFor` and `DynamicIf` to the Forms DSL.
We will add support for _For_ and _If_ elements to the Forms DSL.
They will be named _FormElementFor_ and _FormElementIf_ in the metamodel, but presented as simply _For_ an _If_ in the UI.
In this first version, we will only support these new constructs inside _Groups_ and _FlexboxContainers_.

In this first version, we will only support the same case as in EEF desktop, namely widgets inside a _Group_.
All these four elements (For, If, Groups, Flexbox) behave as containers for an arbitrary number of sub-elements.
To provide a common API for them, we will introduce an abstract _FormElementDescription_ type, which only contains a _name_ attribute.
Widgets (including FlexboxContainer) _and_ constructs like _For_ and _If_ are sub-types of _FormElementDescription_, but _Group_ is not.
For, If, Group and Flexbox will have a _children : FormElementDescription_ many-valued containment reference.

A _GroupDescription_ currently has a _widgets: WidgetDescription_ reference.
As is the case in EEF desktop, we will introduce a new _ControlDescription_ type as a generalization of _WidgetDescription_, and _GroupDescription.widgets_ will now contain _ControlDescriptions_.
We will not change the name of the _widgets_ reference to avoid breaking existing models.
Except for Groups which can only occur at the root of a Page, this structure allows for arbitrary combinations of all 3 of _For_, _If_ and _Flexbox_.
It's possible to have _For_ without _If_, or with many of them; to have nested _For_ loop; _If_ outside of a _For_ loop, _If_ with an arbitrary number of sub-elements, including nested _If_, etc.

_FlexboxContainer_, which also acts as a container for widgets, will also be modified in a similar way so that it's _children_ are _ControlDescriptions_.

The new _DynamicFor_ element will also extends _ControlDescription_, so that a group can contain both actual widgets and _DynamicFor_.

_DynamicFor_ will have two attributes:
_FormElementFor_ will have two attributes:

* `iterationExpression`: an AQL expression to be evaluated when rendering the form, which should return a collection of (arbitrary) elements;
* `iterator`: a (fixed) variable name.

It will also _contain_ an arbitrary number if _DynamicIf_, which has a `predicateExpression` attribute and a `control: ControlDescription[1]` containment reference.
The semantics of the element is that it's children element will be rendered once for every element in the collection returned by `iterationExpression`, in order.
For each value _V_ returned by `iterationExpression`, all the children are rendered in a context where _V_ is bound to a variable named as specified by _iterator_.

_FormElementIf_ will have a single attribute:

The semantics will be the same as in EEF desktop.
* `predicateExpression`: an AQL expression to be evaluated when rendering the form, which should return a boolean.
If placed inside a _FormElementFor_ (or several nested ones), the expression has access to its (their) iterator variable.

When rendering a _FormDescription_ which uses _DynamicFor_ & _DynamicIfs_, in the context of a _FormDescription Editor_ these elements will not be visible.
The Form will display as if all the widgets inside all the _DynamicIfs_ of a _DynamicFor_ were defined directly inside the parent _GroupDescription_.
The semantics of the element is simple: the children of the _FormElementIf_ are only rendered if the expression returned `true`.

== Status

Expand All @@ -44,7 +47,5 @@ Accepted.
* Any custom widget's metamodel will need to be regenerated.
This is required for the custom widget's child creation extender to be able to be created inside the new _DynamicIf_ elements.
* With this, it will become possible to express more generic and dynamic form descriptions, including the equivalent of https://github.com/eclipse-sirius/sirius-desktop/blob/master/plugins/org.eclipse.sirius.properties.defaultrules/model/properties.odesign[Sirius Desktop defaults properties rules].
* With _DynamicIf_ containing _ControlDescriptions_ instead of actual widgets, it should be possible to create nested loops.
* The first version if the mechanism introduced here will be generalized to allow For/If in as many places as possible (and meaningful) in the Forms DSL, notable on Groups and Pages.
Note that pages already support `semanticCandidatesExpression` which cover part of the same uses cases but is less powerful.
* We will also investigate generalizing this mechanism to the diagrams DSL if/where it can make sense.
72 changes: 72 additions & 0 deletions doc/adrs/108_improve_widget_id_computation.adoc
@@ -0,0 +1,72 @@
= ADR-108 - Improve the widget id computation

== Context

When rendering a `Form`, we currently compute the id of a widget instance using a combination of:

. the (EMF) URI of the underlying semantic element;
. a integer counter which is incremented on every use (every time a new id is computed by `WidgetIdProvider`).

Both of these inputs are passed as variables (resp. `self` and `widgetIdProviderCounter`).

With the addition of conditional widgets and for loops, this is not enough to reliably produce consistent ids for the "same" widget instances accross renders.

For example, assume a group with two children widgets W1 and W2 on the same semantic element (same `self`), but with the first widget wrapped inside an `IfDescription`.
On a first render, where the first widget's condition is `true`, we will render W1 and W2 with ids `${uri(self)}#0` and `${uri(self)}#1`.
After a model change and a new render where W1's condition is now `false`, we will render W2 with an id of `${uri(self)}#0`, which was the id of W1 in the previous state.

More complex scenarios are of course possible when `ForDescription` where a given widget description can produce a different number of widget instances on each render.

The (bad) consequence is that the frontend uses the widget's id to track the user focus, and more dangerously, to invoke edition operations requested by the user.
With unstable and unpredicable ids, it is possible for the user to request a (possibly dangerous) operation on a widget it knows under a given id, but the backend to invoke the corresponding action on a different widget (the one with the given id at the time the backend executes the request).

== Decision

We will improve `WidgetIdProvider` by using the same approach as already used for diagram nodes (see ADR-007 - Adopt stable identifiers for diagrams).

The input variables that will be used will be:

. `parentElementId` (a String): the id of the widget's parent element in the `Form`.
Passed by the parent element's `render()` method in the VariableManager it creates to render its children.
. `controlDescriptionId` (a String): the id of the control's `AbstractControlDescription`.
Passed by the widget's own `render()` method in a child VariableManager used to compute it's id.
. `targetObjectId` (a String): the id of the semantic element (`self`) for the widget.
Passed by the widget's own `render()` method in a child VariableManager used to compute it's id.
. `widgetLabel` (a String): the display label of the widget.
This will be used instead of the numeric counter.
The rationale is that inside a given parent (`parentElementId`), if a given control description produces multiple instances for the same semantic element, they should have distinctive labels, or the end-user would not be able to distinguish them anyway.
Passed by the widget's own `render()` method in a child VariableManager used to compute it's id.

These variables will be combined to produce a `UUID` using the same approach as in `NodeComponent.computeNodeId(String)`:

[source,java]
----
private String computeWidgetId(String parentElementId, String controlDescriptionId, String targetObjectId, String label) {
String rawIdentifier = parentElementId + controlDescriptionId + targetObjectId + label;
return UUID.nameUUIDFromBytes(rawIdentifier.getBytes()).toString();
}
----

We will no longer use the `WidgetIdCounter`, even combined with the previous values.
It makes the id of a widget depend on the number of widgets rendered before it, which is not stable in the presence of loops and conditionals.

The widget's label will need to be computed _before_ its id so that we can pass the label to the id provider.

All widgets will need to provide a `Function<VariableManager, String> targetObjectIdProvider`, as is done on e.g. diagram elements.
The implementation of this `targetObjectIdProvider` will be based on `IObjectService`;

[source,java]
----
this.self(variableManager).map(this.objectService::getId).orElse(null)
----

== Status

Accepted

== Consequences

* Widgets will have a more stable id which no longer depends on the number of widgets rendered before them inside the same form.
* The widgets' id will depend on the text of their labels, which technically may change between renders of "the same" widget.
This is judged acceptable as the label is the main way an end-user _identifies_ a widget in the UI.
Making this label dynamic for a given widget would produce a confusing UI anyway so should probably be avoided.
@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2019, 2022 Obeo.
* Copyright (c) 2019, 2023 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
Expand All @@ -12,6 +12,7 @@
*******************************************************************************/
package org.eclipse.sirius.components.compatibility.emf.properties;

import java.util.List;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Function;
Expand Down Expand Up @@ -44,16 +45,20 @@ public class EBooleanIfDescriptionProvider {

private final IPropertiesValidationProvider propertiesValidationProvider;

public EBooleanIfDescriptionProvider(ComposedAdapterFactory composedAdapterFactory, IPropertiesValidationProvider propertiesValidationProvider) {
private final Function<VariableManager, String> semanticTargetIdProvider;

public EBooleanIfDescriptionProvider(ComposedAdapterFactory composedAdapterFactory, IPropertiesValidationProvider propertiesValidationProvider, Function<VariableManager, String> semanticTargetIdProvider) {
this.composedAdapterFactory = Objects.requireNonNull(composedAdapterFactory);
this.propertiesValidationProvider = Objects.requireNonNull(propertiesValidationProvider);
this.semanticTargetIdProvider = Objects.requireNonNull(semanticTargetIdProvider);
}

public IfDescription getIfDescription() {
// @formatter:off
return IfDescription.newIfDescription(IF_DESCRIPTION_ID)
.targetObjectIdProvider(this.semanticTargetIdProvider)
.predicate(this.getPredicate())
.widgetDescription(this.getCheckboxDescription())
.controlDescriptions(List.of(this.getCheckboxDescription()))
.build();
// @formatter:on
}
Expand All @@ -68,6 +73,7 @@ private Function<VariableManager, Boolean> getPredicate() {
private CheckboxDescription getCheckboxDescription() {
// @formatter:off
return CheckboxDescription.newCheckboxDescription(CHECKBOX_DESCRIPTION_ID)
.targetObjectIdProvider(this.semanticTargetIdProvider)
.idProvider(new WidgetIdProvider())
.labelProvider(this.getLabelProvider())
.valueProvider(this.getValueProvider())
Expand Down
@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2019, 2022 Obeo.
* Copyright (c) 2019, 2023 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
Expand Down Expand Up @@ -58,16 +58,20 @@ public class EEnumIfDescriptionProvider {

private final IPropertiesValidationProvider propertiesValidationProvider;

public EEnumIfDescriptionProvider(ComposedAdapterFactory composedAdapterFactory, IPropertiesValidationProvider propertiesValidationProvider) {
private final Function<VariableManager, String> semanticTargetIdProvider;

public EEnumIfDescriptionProvider(ComposedAdapterFactory composedAdapterFactory, IPropertiesValidationProvider propertiesValidationProvider, Function<VariableManager, String> semanticTargetIdProvider) {
this.composedAdapterFactory = Objects.requireNonNull(composedAdapterFactory);
this.propertiesValidationProvider = Objects.requireNonNull(propertiesValidationProvider);
this.semanticTargetIdProvider = Objects.requireNonNull(semanticTargetIdProvider);
}

public IfDescription getIfDescription() {
// @formatter:off
return IfDescription.newIfDescription(IF_DESCRIPTION_ID)
.targetObjectIdProvider(this.semanticTargetIdProvider)
.predicate(this.getPredicate())
.widgetDescription(this.getRadioDescription())
.controlDescriptions(List.of(this.getRadioDescription()))
.build();
// @formatter:on
}
Expand All @@ -86,6 +90,7 @@ private Function<VariableManager, Boolean> getPredicate() {
private RadioDescription getRadioDescription() {
// @formatter:off
return RadioDescription.newRadioDescription(RADIO_DESCRIPTION_ID)
.targetObjectIdProvider(this.semanticTargetIdProvider)
.idProvider(new WidgetIdProvider())
.labelProvider(this.getLabelProvider())
.optionsProvider(this.getOptionsProvider())
Expand Down
@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2019, 2022 Obeo.
* Copyright (c) 2019, 2023 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
Expand All @@ -12,6 +12,7 @@
*******************************************************************************/
package org.eclipse.sirius.components.compatibility.emf.properties;

import java.util.List;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Function;
Expand Down Expand Up @@ -45,16 +46,20 @@ public class EStringIfDescriptionProvider {

private final IPropertiesValidationProvider propertiesValidationProvider;

public EStringIfDescriptionProvider(ComposedAdapterFactory composedAdapterFactory, IPropertiesValidationProvider propertiesValidationProvider) {
private final Function<VariableManager, String> semanticTargetIdProvider;

public EStringIfDescriptionProvider(ComposedAdapterFactory composedAdapterFactory, IPropertiesValidationProvider propertiesValidationProvider, Function<VariableManager, String> semanticTargetIdProvider) {
this.composedAdapterFactory = Objects.requireNonNull(composedAdapterFactory);
this.propertiesValidationProvider = Objects.requireNonNull(propertiesValidationProvider);
this.semanticTargetIdProvider = Objects.requireNonNull(semanticTargetIdProvider);
}

public IfDescription getIfDescription() {
// @formatter:off
return IfDescription.newIfDescription(IF_DESCRIPTION_ID)
.targetObjectIdProvider(this.semanticTargetIdProvider)
.predicate(this.getPredicate())
.widgetDescription(this.getTextareaDescription())
.controlDescriptions(List.of(this.getTextareaDescription()))
.build();
// @formatter:on
}
Expand All @@ -72,6 +77,7 @@ private Function<VariableManager, Boolean> getPredicate() {
private TextareaDescription getTextareaDescription() {
// @formatter:off
return TextareaDescription.newTextareaDescription(TEXTAREA_DESCRIPTION_ID)
.targetObjectIdProvider(this.semanticTargetIdProvider)
.idProvider(new WidgetIdProvider())
.labelProvider(this.getLabelProvider())
.valueProvider(this.getValueProvider())
Expand Down

0 comments on commit 010ab5f

Please sign in to comment.