Tech Insights

Classic to Touch UI Migration for AEM: Multifields

In our previous post on Classic to TouchUI migration we focused on one quirk of this migration, Page Properties. In this post,  we focus on one more quirk about what are called multifields.

Adobe Experience Manager provides a lot of different out-of-the-box fields for creating component dialogs. Multifield is one of them. Multifield is used for repeated instances of the same fields or groups of fields, allowing us to add, reorder, or remove its items. During the migration to TouchUI, we found out that this field is quite challenging in trying to ensure backwards compatibility. Let’s look at the differences between ClassicUI and TouchUI multifields.

First of all, it depends on the complexity of a multifield item. Let’s assume we have a simple multifield where each item is a  text field. In this case, the content will be stored as a multi-string value.

But what if there are several types of field in a multifield item? Then it depends on whether we’re talking about a ClassicUI widget or a TouchUI component.

In the case of ClassicUI, the developers would usually have had to create a custom xtype in order to save several fields into one. The data was usually stored in JSON format.

For TouchUI, a CoralUI multifield component has out-of-the-box support of multifield items with several fields (i.e., composite multifield). But there is a catch. They are stored in subnodes under the component.

That causes a huge issue with backwards compatibility and migration of components with such multifields to TouchUI.

Possible Solutions

So what can we do with this issue in TouchUI? We found several approaches proposed by AEM community members.

Option 1: Update the component’s code to retrieve data from nodes instead of JSON and create a script to convert all JSON data and save it into nodes, as suggested in this thread.

Pros: – We don’t have any problems with subsequent components created after migration – All data is stored in the same format compatible with the Coral3 multifield

Cons: – Changing large amounts of content via script in a production environment can be risky.

Option 2: Switch to Coral2 multifield and use the ACS commons Multifield Extension.

Pros: – No changes to the content – No changes to the back-end multifield processing logic

Cons: – The ACS commons Multifield Extension is officially deprecated – The Multifield Extension does not support Coral3 multifield, so this is a short-term solution, which will require additional migration to Coral3 after Coral2 is deprecated.

Option 3: Create a custom component that saves multifield items into JSON nodes, as suggested in this adaptTo() talk.

Pros: – No changes to the content – No changes to the back-end multifield processing logic

Cons: – In the long term this approach will require more maintenance and will never be fully compatible with OOTB Coral multifield.

Want to join our team and work on open source projects?

Our Solution

After weighing all the pros and cons of these solutions, we came to the conclusion that none of them are 100% suitable for us, so we came up with our own approach.

  1. The Coral3 multifield component is extended to provide backward compatibility with legacy JSON data
  2. New data is always saved into nodes
  3. When opening an old instance of component with legacy data, JSON is parsed into form fields
  4. When saving such a component, legacy data is replaced by nodes

This strategy gives us the following advantages:

  • No scripts and no risky changes to the content—the data will be gradually updated when the content is changed
  • New components are compatible with OOTB multifield, so at some point in the future we can potentially remove this customization and move back to Coral3 multifield

In order to implement this approach, first we need to create a new form component that extends Coral3 multifield. To achieve that, we need to point the component’s sling:resourceSuperType to granite/ui/components/coral/foundation/form/multifield.

Then, we modify the existing multifield so that it parses the legacy JSON structure and retrieves all existing fields. There is one specific edge case that we needed to cover. Some legacy components had only one field in the item, but still saved their structure into JSON. The resulting data looked like this:

./links String[] {"url":"https://www.google.com"}{"url":"https://www.adobe.com"}{"url":"https://www.example.com"}

Such fields should be converted to simple TouchUI multifields. Here is what the component’s renderer.jsp file looks like (some parts omitted for brevity):

<%
...
boolean isComposite = cfg.get("composite", false);
//customizations
boolean isLegacy = MultifieldUtils.isLegacy(values);
boolean isEmpty = isEmpty(slingRequest, name, values);
String sourceProperty = cfg.get("sourceProperty", String.class);
...
 
%><coral-multifield <%= attrs.build() %>> <%
try {
    if(isLegacy){
           for (Object v : values) {
               %><coral-multifield-item><% include(field, MultifieldUtils.createValueMap(name, v.toString(), sourceProperty), cmp, slingRequest); %></coral-multifield-item><%
           }
       } else if (isComposite) {
           ...
   }
...
 
   <%!
...
 
private static boolean isEmpty(SlingHttpServletRequest slingRequest, String name, Object[] values) {
   Resource contentResource = getContentResource(slingRequest, name);
   if (contentResource != null && contentResource.hasChildren()) {
       return false;
   }
   if (values.length > 0) {
       return false;
   }
   return true;
}
%>
See more See less
public class MultifieldUtils {
 
   private static final Logger LOG = LoggerFactory.getLogger(MultifieldUtils.class);
 
   private MultifieldUtils() {
   }
 
   /**
    * Retrieves a List of ValueMap objects representing the values of multifield items
    * works with both legacy (JSON) and new (node) formats
    *
    * @param resource  component resource
    * @param fieldName the name of a multifield's field
    * @return a list of ValueMap objects
    */
   public static List<ValueMap> getItems(Resource resource, String fieldName) {
       return getItems(resource, fieldName, null);
   }
 
   /**
    * Retrieves a List of ValueMap objects representing the values of multifield items
    * works with both legacy (JSON) and new (node) formats
    *
    * @param resource       component resource
    * @param fieldName      the name of a multifield's field
    * @param sourceProperty in case a custom ClassicUI field is transferred to a simple Coral3 multifield,
    *                       represents the name of the property from JSON to be mapped to the field in JCR
    * @return a list of ValueMap objects
    */
   public static List<ValueMap> getItems(Resource resource, String fieldName, String sourceProperty) {
       try {
           Resource multifieldNode = resource.getChild(fieldName);
           if (multifieldNode == null || ResourceUtil.isNonExistingResource(multifieldNode){
return Collections.emptyList(); //multifield is not configured
           }
 
           List<Resource> children = StreamSupport.stream(multifieldNode.getChildren().spliterator(), false).collect(Collectors.toList());
           if (!children.isEmpty()) { // multifield items saved as nodes
               return children.stream()
                       .map(Resource::getValueMap)
                       .collect(Collectors.toList());
           }
           Object[] multifieldValues = resource.getValueMap().get(fieldName, new Object[0]);
           if (isLegacy(multifieldValues)) { // legacy JSON format
               return Arrays.stream(multifieldValues)
                       .map(value -> createValueMap(fieldName, value.toString(), sourceProperty))
                       .collect(Collectors.toList());
           }
           return Arrays.stream(multifieldValues) // a simple array
                   .map(value -> createValueMap(fieldName, value))
                   .collect(Collectors.toList());
 
       } catch (Exception e) {
           LOG.error("Can't get items form field {}: {}", fieldName, e);
       }
 
       return Collections.emptyList();
   }
   /**
    * @param fieldValues an array representing the values of a multifield
    * @return true, if the field contains legacy (JSON) structure
    */
   public static boolean isLegacy(Object[] fieldValues) {
       return fieldValues.length > 0 && fieldValues[0].toString().trim().startsWith("{");
   }
   /**
    * @param name  field name
    * @param value field value
    * @return a single entry value map with the given name and value
    */
   public static ValueMap createValueMap(String name, Object value) {
       ValueMap map = new ValueMapDecorator(new HashMap<>());
       map.put(name, value);
 
       return map;
   }
   /**
    * Creates a value map from a JSON string
    *
    * @param fieldName      in case a customComplex field is transferred to a simple coral multifield,
    *                       represents the name of the field in JCR
    * @param jsonString     a JSON string representing a multifield item
    * @param sourceProperty (optional) in case a custom ClassicUI field is transferred to a simple Coral3 multifield,
    *                       represents the name of the property from JSON to be mapped to the field in JCR
    * @return a valueMap representing a multifield item
    */
   public static ValueMap createValueMap(String fieldName, String jsonString, String sourceProperty) {
       ValueMap map = new ValueMapDecorator(new HashMap<>());
 
       JsonObject itemJson = new JsonParser().parse(jsonString).getAsJsonObject();
       if (StringUtils.isNotBlank(sourceProperty)) {
           String sourceFieldValue = itemJson.get(sourceProperty).getAsString();
           map.put(fieldName, sourceFieldValue);
       } else {
           map.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
           itemJson.entrySet().forEach(entry -> map.put(entry.getKey(), entry.getValue().getAsString()));
       }
 
       return map;
   }
 
}
See more See less

In order to retrieve multifield data in component model/wcmUse class, we also need to check whether the data is stored in nodes, a JSON array, or as a simple array. That’s why our MultifieldUtils class also contains a couple of methods that allow us to consistently work with stored data and get a list of valueMap objects independently of how data is currently stored in JCR.

Author: Liubou Masiuk