[ofbiz-framework] branch trunk updated: Improved: MacroFormRenderer refactoring of label, display and text fields

Previous Topic Next Topic
 
classic Classic list List threaded Threaded
1 message Options
Reply | Threaded
Open this post in threaded view
|

[ofbiz-framework] branch trunk updated: Improved: MacroFormRenderer refactoring of label, display and text fields

danwatford
This is an automated email from the ASF dual-hosted git repository.

danwatford pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 3795727  Improved: MacroFormRenderer refactoring of label, display and text fields
3795727 is described below

commit 3795727ea2a61e686c1d09cfaa792914bd4f8730
Author: Daniel Watford <[hidden email]>
AuthorDate: Tue Jan 12 10:40:58 2021 +0000

    Improved: MacroFormRenderer refactoring of label, display and text
    fields
   
    (OFBIZ-11900)
   
    New RenderableFtl elements to represent pre-rendered FTL strings and FTL
    macro calls. RenderableFtl elements are able to render themselves to
    strings which are processed as an FTL template by the FtlWriter class.
   
    For labels, display fields and text fields, MacroFormRenderer no longer
    generates FTL to write to a template itself, but instead calls
    RenderableFtlFormElementsBuilder to create corresponding RenderableFtl
    elements which are then processed by FtlWriter. This is a WIP to reduce
    complexity in MacroFormRenderer.
---
 .../org/apache/ofbiz/content/cms/CmsEvents.java    |   9 +-
 .../ofbiz/widget/renderer/macro/FtlWriter.java     |  34 +-
 .../widget/renderer/macro/MacroFormRenderer.java   | 355 ++----------
 .../widget/renderer/macro/MacroScreenRenderer.java |   8 +-
 .../macro/RenderableFtlFormElementsBuilder.java    | 624 +++++++++++++++++++++
 .../parameter/MacroCallParameterBooleanValue.java  |  36 ++
 .../parameter/MacroCallParameterMapValue.java      |  48 ++
 .../parameter/MacroCallParameterStringValue.java   |  36 ++
 .../macro/parameter/MacroCallParameterValue.java   |  23 +
 .../renderer/macro/renderable/RenderableFtl.java   |  26 +
 .../macro/renderable/RenderableFtlMacroCall.java   | 102 ++++
 .../macro/renderable/RenderableFtlNoop.java        |  31 +
 .../macro/renderable/RenderableFtlSequence.java    |  58 ++
 .../macro/renderable/RenderableFtlString.java      |  67 +++
 .../widget/renderer/macro/MacroCallMatcher.java    | 100 ++++
 .../MacroCallParameterBooleanValueMatcher.java     |  50 ++
 .../macro/MacroCallParameterMapValueMatcher.java   |  57 ++
 .../renderer/macro/MacroCallParameterMatcher.java  |  90 +++
 .../MacroCallParameterStringValueMatcher.java      |  50 ++
 .../renderer/macro/MacroFormRendererTest.java      | 368 +++++-------
 .../RenderableFtlFormElementsBuilderTest.java      | 238 ++++++++
 21 files changed, 1865 insertions(+), 545 deletions(-)

diff --git a/applications/content/src/main/java/org/apache/ofbiz/content/cms/CmsEvents.java b/applications/content/src/main/java/org/apache/ofbiz/content/cms/CmsEvents.java
index 6e1e758..1605280 100644
--- a/applications/content/src/main/java/org/apache/ofbiz/content/cms/CmsEvents.java
+++ b/applications/content/src/main/java/org/apache/ofbiz/content/cms/CmsEvents.java
@@ -51,9 +51,6 @@ import org.apache.ofbiz.widget.renderer.ScreenRenderer;
 import org.apache.ofbiz.widget.renderer.VisualTheme;
 import org.apache.ofbiz.widget.renderer.macro.MacroFormRenderer;
 
-import freemarker.template.TemplateException;
-
-
 /**
  * CmsEvents
  */
@@ -61,6 +58,9 @@ public class CmsEvents {
 
     private static final String MODULE = CmsEvents.class.getName();
 
+    private CmsEvents() {
+    }
+
     public static String cms(HttpServletRequest request, HttpServletResponse response) {
         Delegator delegator = (Delegator) request.getAttribute("delegator");
         LocalDispatcher dispatcher = (LocalDispatcher) request.getAttribute("dispatcher");
@@ -331,9 +331,6 @@ public class CmsEvents {
                             ContentWorker.renderSubContentAsText(dispatcher, contentId, writer, mapKey, templateMap, locale, "text/html", true);
                         }
 
-                    } catch (TemplateException e) {
-                        throw new GeneralRuntimeException(String.format(
-                                "Error creating form renderer while rendering content [%s] with path alias [%s]", contentId, pathInfo), e);
                     } catch (IOException e) {
                         throw new GeneralRuntimeException(String.format(
                                 "Error in the response writer/output stream while rendering content [%s] with path alias [%s]",
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/FtlWriter.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/FtlWriter.java
index 0be368f..730cf2b 100644
--- a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/FtlWriter.java
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/FtlWriter.java
@@ -25,6 +25,7 @@ import org.apache.ofbiz.base.util.Debug;
 import org.apache.ofbiz.base.util.UtilMisc;
 import org.apache.ofbiz.base.util.template.FreeMarkerWorker;
 import org.apache.ofbiz.widget.renderer.VisualTheme;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtl;
 
 import java.io.IOException;
 import java.io.Reader;
@@ -34,6 +35,9 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.WeakHashMap;
 
+/**
+ * Processes FTL templates and writes result to Appendables.
+ */
 public final class FtlWriter {
     private static final String MODULE = FtlWriter.class.getName();
 
@@ -46,17 +50,35 @@ public final class FtlWriter {
         this.visualTheme = visualTheme;
     }
 
-    public void executeMacro(Appendable writer, Locale locale, String macro) {
+    /**
+     * Process the given RenderableFTL as a template and write the result to the Appendable.
+     *
+     * @param writer        The Appendable to write the result of the template processing to.
+     * @param renderableFtl The Renderable FTL to process as a template.
+     */
+    public void processFtl(final Appendable writer, final RenderableFtl renderableFtl) {
+        processFtlString(writer, null, renderableFtl.toFtlString());
+    }
+
+    /**
+     * Process the given FTL string as a template and write the result to the Appendable.
+     *
+     * @param writer    The Appendable to write the result of the template processing to.
+     * @param ftlString The FTL string to process as a template.
+     */
+    public void processFtlString(Appendable writer, Locale locale, String ftlString) {
         try {
-            Environment environment = getEnvironment(writer, locale);
+            final Environment environment = getEnvironment(writer, locale);
             environment.setVariable("visualTheme", FreeMarkerWorker.autoWrap(visualTheme, environment));
-            environment.setVariable("modelTheme", FreeMarkerWorker.autoWrap(visualTheme.getModelTheme(), environment));
-            Reader templateReader = new StringReader(macro);
-            Template template = new Template(new UID().toString(), templateReader, FreeMarkerWorker.getDefaultOfbizConfig());
+            environment.setVariable("modelTheme",
+                    FreeMarkerWorker.autoWrap(visualTheme.getModelTheme(), environment));
+            Reader templateReader = new StringReader(ftlString);
+            Template template = new Template(new UID().toString(), templateReader,
+                    FreeMarkerWorker.getDefaultOfbizConfig());
             templateReader.close();
             environment.include(template);
         } catch (TemplateException | IOException e) {
-            Debug.logError(e, "Error rendering screen thru ftl, macro: " + macro, MODULE);
+            Debug.logError(e, "Error rendering ftl, ftlString: " + ftlString, MODULE);
         }
     }
 
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRenderer.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRenderer.java
index 64728d7..fd8aff4 100644
--- a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRenderer.java
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRenderer.java
@@ -30,7 +30,6 @@ import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
@@ -42,14 +41,12 @@ import javax.servlet.http.HttpSession;
 import org.apache.ofbiz.base.util.Debug;
 import org.apache.ofbiz.base.util.StringUtil;
 import org.apache.ofbiz.base.util.UtilCodec;
-import org.apache.ofbiz.base.util.UtilFormatOut;
 import org.apache.ofbiz.base.util.UtilGenerics;
 import org.apache.ofbiz.base.util.UtilHttp;
 import org.apache.ofbiz.base.util.UtilMisc;
 import org.apache.ofbiz.base.util.UtilProperties;
 import org.apache.ofbiz.base.util.UtilValidate;
 import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
-import org.apache.ofbiz.base.util.template.FreeMarkerWorker;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.security.CsrfUtil;
 import org.apache.ofbiz.webapp.control.RequestHandler;
@@ -97,8 +94,8 @@ import org.jsoup.nodes.Element;
 
 import com.ibm.icu.util.Calendar;
 
-import freemarker.template.Template;
-import freemarker.template.TemplateException;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlMacroCall;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtl;
 
 /**
  * Widget Library - Form Renderer implementation based on Freemarker macros
@@ -106,12 +103,11 @@ import freemarker.template.TemplateException;
 public final class MacroFormRenderer implements FormStringRenderer {
 
     private static final String MODULE = MacroFormRenderer.class.getName();
-    @SuppressWarnings("unused")
-    private final Template macroLibrary;
     private final UtilCodec.SimpleEncoder internalEncoder;
     private final RequestHandler rh;
     private final HttpServletRequest request;
     private final HttpServletResponse response;
+    private final RenderableFtlFormElementsBuilder renderableFtlFormElementsBuilder;
     private final boolean javaScriptEnabled;
     private final VisualTheme visualTheme;
     private final FtlWriter ftlWriter;
@@ -119,13 +115,13 @@ public final class MacroFormRenderer implements FormStringRenderer {
     private boolean widgetCommentsEnabled = false;
 
     public MacroFormRenderer(String macroLibraryPath, HttpServletRequest request, HttpServletResponse response)
-            throws TemplateException, IOException {
-        this(macroLibraryPath, request, response, null);
+            throws IOException {
+        this(macroLibraryPath, request, response, null, null);
     }
 
     public MacroFormRenderer(String macroLibraryPath, HttpServletRequest request, HttpServletResponse response,
-                             FtlWriter ftlWriter) throws TemplateException, IOException {
-        this.macroLibrary = FreeMarkerWorker.getTemplate(macroLibraryPath);
+                             FtlWriter ftlWriter, RenderableFtlFormElementsBuilder renderableFtlFormElementsBuilder)
+            throws IOException {
         this.request = request;
         this.response = response;
         this.visualTheme = ThemeFactory.resolveVisualTheme(request);
@@ -133,12 +129,9 @@ public final class MacroFormRenderer implements FormStringRenderer {
         this.javaScriptEnabled = UtilHttp.isJavaScriptEnabled(request);
         internalEncoder = UtilCodec.getEncoder("string");
         this.ftlWriter = ftlWriter != null ? ftlWriter : new FtlWriter(macroLibraryPath, this.visualTheme);
-    }
-
-    @Deprecated
-    public MacroFormRenderer(String macroLibraryPath, Appendable writer, HttpServletRequest request, HttpServletResponse response)
-            throws TemplateException, IOException {
-        this(macroLibraryPath, request, response);
+        this.renderableFtlFormElementsBuilder = renderableFtlFormElementsBuilder != null
+                ? renderableFtlFormElementsBuilder
+                : new RenderableFtlFormElementsBuilder(this.visualTheme, rh, request, response);
     }
 
     private static String encodeDoubleQuotes(String htmlString) {
@@ -167,8 +160,12 @@ public final class MacroFormRenderer implements FormStringRenderer {
         this.renderPagination = renderPagination;
     }
 
+    public void writeFtlElement(final Appendable writer, final RenderableFtl renderableFtl) {
+        ftlWriter.processFtl(writer, renderableFtl);
+    }
+
     private void executeMacro(Appendable writer, String macro) {
-        ftlWriter.executeMacro(writer, null, macro);
+        ftlWriter.processFtlString(writer, null, macro);
     }
 
     /**
@@ -178,7 +175,7 @@ public final class MacroFormRenderer implements FormStringRenderer {
      * @param macro
      */
     private void executeMacro(Appendable writer, Locale locale, String macro) {
-        ftlWriter.executeMacro(writer, locale, macro);
+        ftlWriter.processFtlString(writer, locale, macro);
     }
 
     private String encode(String value, ModelFormField modelFormField, Map<String, Object> context) {
@@ -195,139 +192,23 @@ public final class MacroFormRenderer implements FormStringRenderer {
     }
 
     public void renderLabel(Appendable writer, Map<String, Object> context, ModelScreenWidget.Label label) {
-        String labelText = label.getText(context);
-        if (UtilValidate.isEmpty(labelText)) {
-            // nothing to render
-            return;
-        }
-        StringWriter sr = new StringWriter();
-        sr.append("<@renderLabel ");
-        sr.append("text=\"");
-        sr.append(labelText);
-        sr.append("\"");
-        sr.append(" />");
-        executeMacro(writer, sr.toString());
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.label(context, label);
+        writeFtlElement(writer, renderableFtl);
     }
 
     @Override
-    public void renderDisplayField(Appendable writer, Map<String, Object> context, DisplayField displayField) throws IOException {
-        ModelFormField modelFormField = displayField.getModelFormField();
-        String idName = modelFormField.getCurrentContainerId(context);
-        String description = displayField.getDescription(context);
-        String type = displayField.getType();
-        String imageLocation = displayField.getImageLocation(context);
-        Integer size = Integer.valueOf("0");
-        String title = "";
-        if (UtilValidate.isNotEmpty(displayField.getSize())) {
-            try {
-                size = Integer.parseInt(displayField.getSize());
-            } catch (NumberFormatException nfe) {
-                Debug.logError(nfe, "Error reading size of a field fieldName=" + displayField.getModelFormField().getFieldName() + " FormName= "
-                        + displayField.getModelFormField().getModelForm().getName(), MODULE);
-            }
-        }
-        ModelFormField.InPlaceEditor inPlaceEditor = displayField.getInPlaceEditor();
-        boolean ajaxEnabled = inPlaceEditor != null && this.javaScriptEnabled;
-        if (UtilValidate.isNotEmpty(description) && size > 0 && description.length() > size) {
-            title = description;
-            description = description.substring(0, size - 8) + "..." + description.substring(description.length() - 5);
-        }
-        StringWriter sr = new StringWriter();
-        sr.append("<@renderDisplayField ");
-        sr.append("type=\"");
-        sr.append(type);
-        sr.append("\" imageLocation=\"");
-        sr.append(imageLocation);
-        sr.append("\" idName=\"");
-        sr.append(idName);
-        sr.append("\" description=\"");
-        sr.append(encodeDoubleQuotes(description));
-        sr.append("\" title=\"");
-        sr.append(title);
-        sr.append("\" class=\"");
-        sr.append(modelFormField.getWidgetStyle());
-        sr.append("\" alert=\"");
-        sr.append(modelFormField.shouldBeRed(context) ? "true" : "false");
-        if (ajaxEnabled) {
-            String url = inPlaceEditor.getUrl(context);
-            StringBuffer extraParameterBuffer = new StringBuffer();
-            String extraParameter;
-
-            Map<String, Object> fieldMap = inPlaceEditor.getFieldMap(context);
-            Set<Entry<String, Object>> fieldSet = fieldMap.entrySet();
-            Iterator<Entry<String, Object>> fieldIterator = fieldSet.iterator();
-            int count = 0;
-            extraParameterBuffer.append("{");
-            while (fieldIterator.hasNext()) {
-                count++;
-                Entry<String, Object> field = fieldIterator.next();
-                extraParameterBuffer.append(field.getKey() + ":'" + (String) field.getValue() + "'");
-                if (count < fieldSet.size()) {
-                    extraParameterBuffer.append(',');
-                }
+    public void renderDisplayField(Appendable writer, Map<String, Object> context, DisplayField displayField) {
+        writeFtlElement(writer,
+                renderableFtlFormElementsBuilder.displayField(context, displayField, this.javaScriptEnabled));
 
-            }
-            extraParameterBuffer.append("}");
-            extraParameter = extraParameterBuffer.toString();
-            sr.append("\" inPlaceEditorUrl=\"");
-            sr.append(url);
-            sr.append("\" inPlaceEditorParams=\"");
-            StringWriter inPlaceEditorParams = new StringWriter();
-            inPlaceEditorParams.append("{name: '");
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getParamName())) {
-                inPlaceEditorParams.append(inPlaceEditor.getParamName());
-            } else {
-                inPlaceEditorParams.append(modelFormField.getFieldName());
-            }
-            inPlaceEditorParams.append("'");
-            inPlaceEditorParams.append(", method: 'POST'");
-            inPlaceEditorParams.append(", submitdata: " + extraParameter);
-            inPlaceEditorParams.append(", type: 'textarea'");
-            inPlaceEditorParams.append(", select: 'true'");
-            inPlaceEditorParams.append(", onreset: function(){jQuery('#cc_" + idName + "').css('background-color', 'transparent');}");
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getCancelText())) {
-                inPlaceEditorParams.append(", cancel: '" + inPlaceEditor.getCancelText() + "'");
-            } else {
-                inPlaceEditorParams.append(", cancel: 'Cancel'");
-            }
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getClickToEditText())) {
-                inPlaceEditorParams.append(", tooltip: '" + inPlaceEditor.getClickToEditText() + "'");
-            }
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getFormClassName())) {
-                inPlaceEditorParams.append(", cssclass: '" + inPlaceEditor.getFormClassName() + "'");
-            } else {
-                inPlaceEditorParams.append(", cssclass: 'inplaceeditor-form'");
-            }
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getLoadingText())) {
-                inPlaceEditorParams.append(", indicator: '" + inPlaceEditor.getLoadingText() + "'");
-            }
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getOkControl())) {
-                inPlaceEditorParams.append(", submit: ");
-                if (!"false".equals(inPlaceEditor.getOkControl())) {
-                    inPlaceEditorParams.append("'");
-                }
-                inPlaceEditorParams.append(inPlaceEditor.getOkControl());
-                if (!"false".equals(inPlaceEditor.getOkControl())) {
-                    inPlaceEditorParams.append("'");
-                }
-            } else {
-                inPlaceEditorParams.append(", submit: 'OK'");
-            }
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getRows())) {
-                inPlaceEditorParams.append(", rows: '" + inPlaceEditor.getRows() + "'");
-            }
-            if (UtilValidate.isNotEmpty(inPlaceEditor.getCols())) {
-                inPlaceEditorParams.append(", cols: '" + inPlaceEditor.getCols() + "'");
-            }
-            inPlaceEditorParams.append("}");
-            sr.append(inPlaceEditorParams.toString());
-        }
-        sr.append("\" />");
-        executeMacro(writer, sr.toString());
         if (displayField instanceof DisplayEntityField) {
-            makeHyperlinkString(writer, ((DisplayEntityField) displayField).getSubHyperlink(), context);
+            writeFtlElement(writer,
+                    renderableFtlFormElementsBuilder.makeHyperlinkString(((DisplayEntityField) displayField).getSubHyperlink(),
+                            context));
         }
-        this.appendTooltip(writer, context, modelFormField);
+
+        final ModelFormField modelFormField = displayField.getModelFormField();
+        appendTooltip(writer, context, modelFormField);
     }
 
     @Override
@@ -357,107 +238,18 @@ public final class MacroFormRenderer implements FormStringRenderer {
     }
 
     @Override
-    public void renderTextField(Appendable writer, Map<String, Object> context, TextField textField) throws IOException {
-        ModelFormField modelFormField = textField.getModelFormField();
-        String name = modelFormField.getParameterName(context);
-        String className = "";
-        String alert = "false";
-        String mask = "";
-        String placeholder = textField.getPlaceholder(context);
-        if (UtilValidate.isNotEmpty(modelFormField.getWidgetStyle())) {
-            className = modelFormField.getWidgetStyle();
-            if (modelFormField.shouldBeRed(context)) {
-                alert = "true";
-            }
-        }
-        String value = modelFormField.getEntry(context, textField.getDefaultValue(context));
-        String textSize = Integer.toString(textField.getSize());
-        String maxlength = "";
-        if (textField.getMaxlength() != null) {
-            maxlength = Integer.toString(textField.getMaxlength());
-        }
-        String event = modelFormField.getEvent();
-        String action = modelFormField.getAction(context);
-        String id = modelFormField.getCurrentContainerId(context);
-        String clientAutocomplete = "false";
-        //check for required field style on single forms
-        if ("single".equals(modelFormField.getModelForm().getType()) && modelFormField.getRequiredField()) {
-            String requiredStyle = modelFormField.getRequiredFieldStyle();
-            if (UtilValidate.isEmpty(requiredStyle)) {
-                requiredStyle = "required";
-            }
-            if (UtilValidate.isEmpty(className)) {
-                className = requiredStyle;
-            } else {
-                className = requiredStyle + " " + className;
-            }
-        }
-        List<ModelForm.UpdateArea> updateAreas = modelFormField.getOnChangeUpdateAreas();
-        boolean ajaxEnabled = updateAreas != null && this.javaScriptEnabled;
-        if (textField.getClientAutocompleteField() || ajaxEnabled) {
-            clientAutocomplete = "true";
-        }
-        if (UtilValidate.isNotEmpty(textField.getMask())) {
-            mask = textField.getMask();
-        }
-        String ajaxUrl = createAjaxParamsFromUpdateAreas(updateAreas, "", context);
-        boolean disabled = modelFormField.getDisabled();
-        boolean readonly = textField.getReadonly();
-        String tabindex = modelFormField.getTabindex();
-        StringWriter sr = new StringWriter();
-        sr.append("<@renderTextField ");
-        sr.append("name=\"");
-        sr.append(name);
-        sr.append("\" className=\"");
-        sr.append(className);
-        sr.append("\" alert=\"");
-        sr.append(alert);
-        sr.append("\" value=\"");
-        sr.append(value);
-        sr.append("\" textSize=\"");
-        sr.append(textSize);
-        sr.append("\" maxlength=\"");
-        sr.append(maxlength);
-        sr.append("\" id=\"");
-        sr.append(id);
-        sr.append("\" event=\"");
-        if (event != null) {
-            sr.append(event);
-        }
-        sr.append("\" action=\"");
-        if (action != null) {
-            sr.append(action);
-        }
-        sr.append("\" disabled=");
-        sr.append(Boolean.toString(disabled));
-        sr.append(" readonly=");
-        sr.append(Boolean.toString(readonly));
-        sr.append(" clientAutocomplete=\"");
-        sr.append(clientAutocomplete);
-        sr.append("\" ajaxUrl=\"");
-        sr.append(ajaxUrl);
-        sr.append("\" ajaxEnabled=");
-        sr.append(Boolean.toString(ajaxEnabled));
-        sr.append(" mask=\"");
-        sr.append(mask);
-        sr.append("\" placeholder=\"");
-        sr.append(placeholder);
-        sr.append("\" tabindex=\"");
-        sr.append(tabindex);
-        sr.append("\" delegatorName=\"");
-        sr.append(((HttpSession) context.get("session")).getAttribute("delegatorName").toString());
-        sr.append("\" />");
-        executeMacro(writer, sr.toString());
-        ModelFormField.SubHyperlink subHyperlink = textField.getSubHyperlink();
-        if (subHyperlink != null && subHyperlink.shouldUse(context)) {
-            makeHyperlinkString(writer, subHyperlink, context);
-        }
+    public void renderTextField(Appendable writer, Map<String, Object> context, TextField textField) {
+        writeFtlElement(writer, renderableFtlFormElementsBuilder.textField(context, textField, javaScriptEnabled));
+
+        writeFtlElement(writer, renderableFtlFormElementsBuilder.makeHyperlinkString(textField.getSubHyperlink(), context));
+
+        final ModelFormField modelFormField = textField.getModelFormField();
         this.addAsterisks(writer, context, modelFormField);
         this.appendTooltip(writer, context, modelFormField);
     }
 
     @Override
-    public void renderTextareaField(Appendable writer, Map<String, Object> context, TextareaField textareaField) throws IOException {
+    public void renderTextareaField(Appendable writer, Map<String, Object> context, TextareaField textareaField) {
         ModelFormField modelFormField = textareaField.getModelFormField();
         String name = modelFormField.getParameterName(context);
         String cols = Integer.toString(textareaField.getCols());
@@ -574,7 +366,7 @@ public final class MacroFormRenderer implements FormStringRenderer {
                         + " Found Value [" + stepString + "]  " + e.getMessage(), MODULE);
             }
             timeValues.append("[");
-            for (int i = 0; i <= 59;) {
+            for (int i = 0; i <= 59; ) {
                 if (i != 0) {
                     timeValues.append(", ");
                 }
@@ -3184,17 +2976,9 @@ public final class MacroFormRenderer implements FormStringRenderer {
         return FlexibleStringExpander.expandString(ajaxUrl, context, locale);
     }
 
-    public void appendTooltip(Appendable writer, Map<String, Object> context, ModelFormField modelFormField) {
+    private void appendTooltip(Appendable writer, Map<String, Object> context, ModelFormField modelFormField) {
         // render the tooltip, in other methods too
-        String tooltip = modelFormField.getTooltip(context);
-        StringWriter sr = new StringWriter();
-        sr.append("<@renderTooltip ");
-        sr.append("tooltip=\"");
-        sr.append(encodeDoubleQuotes(tooltip));
-        sr.append("\" tooltipStyle=\"");
-        sr.append(modelFormField.getTooltipStyle());
-        sr.append("\" />");
-        executeMacro(writer, sr.toString());
+        writeFtlElement(writer, renderableFtlFormElementsBuilder.tooltip(context, modelFormField));
     }
 
     public void makeHyperlinkString(Appendable writer, ModelFormField.SubHyperlink subHyperlink, Map<String, Object> context) throws IOException {
@@ -3217,21 +3001,9 @@ public final class MacroFormRenderer implements FormStringRenderer {
         }
     }
 
-    public void addAsterisks(Appendable writer, Map<String, Object> context, ModelFormField modelFormField) {
-        String requiredField = "false";
-        String requiredStyle = "";
-        if (modelFormField.getRequiredField()) {
-            requiredField = "true";
-            requiredStyle = modelFormField.getRequiredFieldStyle();
-        }
-        StringWriter sr = new StringWriter();
-        sr.append("<@renderAsterisks ");
-        sr.append("requiredField=\"");
-        sr.append(requiredField);
-        sr.append("\" requiredStyle=\"");
-        sr.append(requiredStyle);
-        sr.append("\" />");
-        executeMacro(writer, sr.toString());
+    private void addAsterisks(Appendable writer, Map<String, Object> context, ModelFormField modelFormField) {
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.asterisks(context, modelFormField);
+        writeFtlElement(writer, renderableFtl);
     }
 
     public void appendContentUrl(Appendable writer, String location) throws IOException {
@@ -3418,52 +3190,9 @@ public final class MacroFormRenderer implements FormStringRenderer {
         }
     }
 
-    public void makeHiddenFormLinkAnchor(Appendable writer, String linkStyle, String description, String confirmation, ModelFormField modelFormField,
-                                         HttpServletRequest request, HttpServletResponse response, Map<String, Object> context) {
-        if (UtilValidate.isNotEmpty(description) || UtilValidate.isNotEmpty(request.getAttribute("image"))) {
-            String hiddenFormName = WidgetWorker.makeLinkHiddenFormName(context, modelFormField);
-            String event = "";
-            String action = "";
-            String imgSrc = "";
-            if (UtilValidate.isNotEmpty(modelFormField.getEvent()) && UtilValidate.isNotEmpty(modelFormField.getAction(context))) {
-                event = modelFormField.getEvent();
-                action = modelFormField.getAction(context);
-            }
-            if (UtilValidate.isNotEmpty(request.getAttribute("image"))) {
-                imgSrc = request.getAttribute("image").toString();
-            }
-            StringWriter sr = new StringWriter();
-            sr.append("<@makeHiddenFormLinkAnchor ");
-            sr.append("linkStyle=\"");
-            sr.append(linkStyle == null ? "" : linkStyle);
-            sr.append("\" hiddenFormName=\"");
-            sr.append(hiddenFormName);
-            sr.append("\" event=\"");
-            sr.append(event);
-            sr.append("\" action=\"");
-            sr.append(action);
-            sr.append("\" imgSrc=\"");
-            sr.append(imgSrc);
-            sr.append("\" description=\"");
-            sr.append(description);
-            sr.append("\" confirmation =\"");
-            sr.append(confirmation);
-            sr.append("\" />");
-            executeMacro(writer, sr.toString());
-        }
-    }
-
     @Override
     public void renderContainerFindField(Appendable writer, Map<String, Object> context, ContainerField containerField) throws IOException {
-        final String id = containerField.getModelFormField().getCurrentContainerId(context);
-        String className = UtilFormatOut.checkNull(containerField.getModelFormField().getWidgetStyle());
-        StringWriter sr = new StringWriter();
-        sr.append("<@renderContainerField ");
-        sr.append("id=\"");
-        sr.append(id);
-        sr.append("\" className=\"");
-        sr.append(className);
-        sr.append("\" />");
-        executeMacro(writer, sr.toString());
+        final RenderableFtlMacroCall containerMc = renderableFtlFormElementsBuilder.containerMacroCall(context, containerField);
+        writeFtlElement(writer, containerMc);
     }
 }
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroScreenRenderer.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroScreenRenderer.java
index 7cc9ed1..ce1c3a8 100644
--- a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroScreenRenderer.java
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroScreenRenderer.java
@@ -713,12 +713,8 @@ public class MacroScreenRenderer implements ScreenStringRenderer {
                 Map<String, Object> globalCtx = UtilGenerics.cast(context.get("globalContext"));
                 globalCtx.put("NO_PAGINATOR", true);
                 FormStringRenderer savedRenderer = (FormStringRenderer) context.get("formStringRenderer");
-                MacroFormRenderer renderer = null;
-                try {
-                    renderer = new MacroFormRenderer(modelTheme.getFormRendererLocation("screen"), request, response);
-                } catch (TemplateException e) {
-                    Debug.logError("Not rendering content, error on MacroFormRenderer creation.", MODULE);
-                }
+                MacroFormRenderer renderer = new MacroFormRenderer(
+                        modelTheme.getFormRendererLocation("screen"), request, response);
                 renderer.setRenderPagination(false);
                 context.put("formStringRenderer", renderer);
                 subWidget.renderWidgetString(writer, context, this);
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/RenderableFtlFormElementsBuilder.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/RenderableFtlFormElementsBuilder.java
new file mode 100644
index 0000000..a2a5402
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/RenderableFtlFormElementsBuilder.java
@@ -0,0 +1,624 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilCodec;
+import org.apache.ofbiz.base.util.UtilFormatOut;
+import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilHttp;
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
+import org.apache.ofbiz.webapp.control.RequestHandler;
+import org.apache.ofbiz.widget.WidgetWorker;
+import org.apache.ofbiz.widget.model.ModelForm;
+import org.apache.ofbiz.widget.model.ModelFormField;
+import org.apache.ofbiz.widget.model.ModelFormField.ContainerField;
+import org.apache.ofbiz.widget.model.ModelFormField.DisplayField;
+import org.apache.ofbiz.widget.model.ModelScreenWidget.Label;
+import org.apache.ofbiz.widget.model.ModelTheme;
+import org.apache.ofbiz.widget.renderer.Paginator;
+import org.apache.ofbiz.widget.renderer.VisualTheme;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlMacroCall;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtl;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlMacroCall.RenderableFtlMacroCallBuilder;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlSequence;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlString;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlNoop;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlString.RenderableFtlStringBuilder;
+import org.jsoup.nodes.Element;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.StringWriter;
+import java.net.URI;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Creates RenderableFtl objects used to render the various elements of a form.
+ */
+public final class RenderableFtlFormElementsBuilder {
+    private static final String MODULE = RenderableFtlFormElementsBuilder.class.getName();
+    private final UtilCodec.SimpleEncoder internalEncoder = UtilCodec.getEncoder("string");
+    private final VisualTheme visualTheme;
+    private final RequestHandler requestHandler;
+    private final HttpServletRequest request;
+    private final HttpServletResponse response;
+
+    public RenderableFtlFormElementsBuilder(final VisualTheme visualTheme, final RequestHandler requestHandler,
+                                            final HttpServletRequest request, final HttpServletResponse response) {
+        this.visualTheme = visualTheme;
+        this.requestHandler = requestHandler;
+        this.request = request;
+        this.response = response;
+    }
+
+    public RenderableFtl tooltip(final Map<String, Object> context, final ModelFormField modelFormField) {
+        final String tooltip = modelFormField.getTooltip(context);
+        return RenderableFtlMacroCall.builder()
+                .name("renderTooltip")
+                .stringParameter("tooltip", tooltip)
+                .stringParameter("tooltipStyle", modelFormField.getTitleStyle())
+                .build();
+    }
+
+    public RenderableFtl asterisks(final Map<String, Object> context, final ModelFormField modelFormField) {
+        String requiredField = "false";
+        String requiredStyle = "";
+        if (modelFormField.getRequiredField()) {
+            requiredField = "true";
+            requiredStyle = modelFormField.getRequiredFieldStyle();
+        }
+
+        return RenderableFtlMacroCall.builder()
+                .name("renderAsterisks")
+                .stringParameter("requiredField", requiredField)
+                .stringParameter("requiredStyle", requiredStyle)
+                .build();
+    }
+
+    public RenderableFtl label(final Map<String, Object> context, final Label label) {
+        final String labelText = label.getText(context);
+
+        if (UtilValidate.isEmpty(labelText)) {
+            // nothing to render
+            return RenderableFtlNoop.INSTANCE;
+        }
+        return RenderableFtlMacroCall.builder()
+                .name("renderLabel")
+                .stringParameter("text", labelText)
+                .build();
+    }
+
+    public RenderableFtl displayField(final Map<String, Object> context, final DisplayField displayField,
+                                      final boolean javaScriptEnabled) {
+        ModelFormField modelFormField = displayField.getModelFormField();
+        String idName = modelFormField.getCurrentContainerId(context);
+        String description = displayField.getDescription(context);
+        String type = displayField.getType();
+        String imageLocation = displayField.getImageLocation(context);
+        Integer size = Integer.valueOf("0");
+        String title = "";
+        if (UtilValidate.isNotEmpty(displayField.getSize())) {
+            try {
+                size = Integer.parseInt(displayField.getSize());
+            } catch (NumberFormatException nfe) {
+                Debug.logError(nfe, "Error reading size of a field fieldName="
+                        + displayField.getModelFormField().getFieldName() + " FormName= "
+                        + displayField.getModelFormField().getModelForm().getName(), MODULE);
+            }
+        }
+        ModelFormField.InPlaceEditor inPlaceEditor = displayField.getInPlaceEditor();
+        boolean ajaxEnabled = inPlaceEditor != null && javaScriptEnabled;
+        if (UtilValidate.isNotEmpty(description) && size > 0 && description.length() > size) {
+            title = description;
+            description = description.substring(0, size - 8) + "..." + description.substring(description.length() - 5);
+        }
+
+        final RenderableFtlMacroCallBuilder builder = RenderableFtlMacroCall.builder()
+                .name("renderDisplayField")
+                .stringParameter("type", type)
+                .stringParameter("imageLocation", imageLocation)
+                .stringParameter("idName", idName)
+                .stringParameter("description", description)
+                .stringParameter("title", title)
+                .stringParameter("class", modelFormField.getWidgetStyle())
+                .stringParameter("alert", modelFormField.shouldBeRed(context) ? "true" : "false");
+
+        StringWriter sr = new StringWriter();
+        sr.append("<@renderDisplayField ");
+        if (ajaxEnabled) {
+            String url = inPlaceEditor.getUrl(context);
+            StringBuffer extraParameterBuffer = new StringBuffer();
+            String extraParameter;
+
+            Map<String, Object> fieldMap = inPlaceEditor.getFieldMap(context);
+            Set<Map.Entry<String, Object>> fieldSet = fieldMap.entrySet();
+            Iterator<Map.Entry<String, Object>> fieldIterator = fieldSet.iterator();
+            int count = 0;
+            extraParameterBuffer.append("{");
+            while (fieldIterator.hasNext()) {
+                count++;
+                Map.Entry<String, Object> field = fieldIterator.next();
+                extraParameterBuffer.append(field.getKey() + ":'" + (String) field.getValue() + "'");
+                if (count < fieldSet.size()) {
+                    extraParameterBuffer.append(',');
+                }
+            }
+
+            extraParameterBuffer.append("}");
+            extraParameter = extraParameterBuffer.toString();
+            builder.stringParameter("inPlaceEditorUrl", url);
+
+            StringWriter inPlaceEditorParams = new StringWriter();
+            inPlaceEditorParams.append("{name: '");
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getParamName())) {
+                inPlaceEditorParams.append(inPlaceEditor.getParamName());
+            } else {
+                inPlaceEditorParams.append(modelFormField.getFieldName());
+            }
+            inPlaceEditorParams.append("'");
+            inPlaceEditorParams.append(", method: 'POST'");
+            inPlaceEditorParams.append(", submitdata: " + extraParameter);
+            inPlaceEditorParams.append(", type: 'textarea'");
+            inPlaceEditorParams.append(", select: 'true'");
+            inPlaceEditorParams.append(", onreset: function(){jQuery('#cc_" + idName
+                    + "').css('background-color', 'transparent');}");
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getCancelText())) {
+                inPlaceEditorParams.append(", cancel: '" + inPlaceEditor.getCancelText() + "'");
+            } else {
+                inPlaceEditorParams.append(", cancel: 'Cancel'");
+            }
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getClickToEditText())) {
+                inPlaceEditorParams.append(", tooltip: '" + inPlaceEditor.getClickToEditText() + "'");
+            }
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getFormClassName())) {
+                inPlaceEditorParams.append(", cssclass: '" + inPlaceEditor.getFormClassName() + "'");
+            } else {
+                inPlaceEditorParams.append(", cssclass: 'inplaceeditor-form'");
+            }
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getLoadingText())) {
+                inPlaceEditorParams.append(", indicator: '" + inPlaceEditor.getLoadingText() + "'");
+            }
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getOkControl())) {
+                inPlaceEditorParams.append(", submit: ");
+                if (!"false".equals(inPlaceEditor.getOkControl())) {
+                    inPlaceEditorParams.append("'");
+                }
+                inPlaceEditorParams.append(inPlaceEditor.getOkControl());
+                if (!"false".equals(inPlaceEditor.getOkControl())) {
+                    inPlaceEditorParams.append("'");
+                }
+            } else {
+                inPlaceEditorParams.append(", submit: 'OK'");
+            }
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getRows())) {
+                inPlaceEditorParams.append(", rows: '" + inPlaceEditor.getRows() + "'");
+            }
+            if (UtilValidate.isNotEmpty(inPlaceEditor.getCols())) {
+                inPlaceEditorParams.append(", cols: '" + inPlaceEditor.getCols() + "'");
+            }
+            inPlaceEditorParams.append("}");
+            builder.stringParameter("inPlaceEditorParams", inPlaceEditorParams.toString());
+        }
+
+        return builder.build();
+    }
+
+    public RenderableFtl textField(final Map<String, Object> context, final ModelFormField.TextField textField,
+                                   final boolean javaScriptEnabled) {
+        ModelFormField modelFormField = textField.getModelFormField();
+        String name = modelFormField.getParameterName(context);
+        String className = "";
+        String alert = "false";
+        String mask = "";
+        String placeholder = textField.getPlaceholder(context);
+        if (UtilValidate.isNotEmpty(modelFormField.getWidgetStyle())) {
+            className = modelFormField.getWidgetStyle();
+            if (modelFormField.shouldBeRed(context)) {
+                alert = "true";
+            }
+        }
+        String value = modelFormField.getEntry(context, textField.getDefaultValue(context));
+        String textSize = Integer.toString(textField.getSize());
+        String maxlength = "";
+        if (textField.getMaxlength() != null) {
+            maxlength = Integer.toString(textField.getMaxlength());
+        }
+        String event = modelFormField.getEvent();
+        String action = modelFormField.getAction(context);
+        String id = modelFormField.getCurrentContainerId(context);
+        String clientAutocomplete = "false";
+        //check for required field style on single forms
+        if ("single".equals(modelFormField.getModelForm().getType()) && modelFormField.getRequiredField()) {
+            String requiredStyle = modelFormField.getRequiredFieldStyle();
+            if (UtilValidate.isEmpty(requiredStyle)) {
+                requiredStyle = "required";
+            }
+            if (UtilValidate.isEmpty(className)) {
+                className = requiredStyle;
+            } else {
+                className = requiredStyle + " " + className;
+            }
+        }
+        List<ModelForm.UpdateArea> updateAreas = modelFormField.getOnChangeUpdateAreas();
+        boolean ajaxEnabled = updateAreas != null && javaScriptEnabled;
+        if (textField.getClientAutocompleteField() || ajaxEnabled) {
+            clientAutocomplete = "true";
+        }
+        if (UtilValidate.isNotEmpty(textField.getMask())) {
+            mask = textField.getMask();
+        }
+        String ajaxUrl = createAjaxParamsFromUpdateAreas(updateAreas, "", context);
+        boolean disabled = modelFormField.getDisabled();
+        boolean readonly = textField.getReadonly();
+        String tabindex = modelFormField.getTabindex();
+
+        return RenderableFtlMacroCall.builder()
+                .name("renderTextField")
+                .stringParameter("name", name)
+                .stringParameter("className", className)
+                .stringParameter("alert", alert)
+                .stringParameter("value", value)
+                .stringParameter("textSize", textSize)
+                .stringParameter("maxlength", maxlength)
+                .stringParameter("id", id)
+                .stringParameter("event", event != null ? event : "")
+                .stringParameter("action", action != null ? action : "")
+                .booleanParameter("disabled", disabled)
+                .booleanParameter("readonly", readonly)
+                .stringParameter("clientAutocomplete", clientAutocomplete)
+                .stringParameter("ajaxUrl", ajaxUrl)
+                .booleanParameter("ajaxEnabled", ajaxEnabled)
+                .stringParameter("mask", mask)
+                .stringParameter("placeholder", placeholder)
+                .stringParameter("tabindex", tabindex)
+                .stringParameter("delegatorName", ((HttpSession) context.get("session"))
+                        .getAttribute("delegatorName").toString())
+                .build();
+    }
+
+    public RenderableFtl makeHyperlinkString(final ModelFormField.SubHyperlink subHyperlink,
+                                             final Map<String, Object> context) {
+        if (subHyperlink == null || !subHyperlink.shouldUse(context)) {
+            return RenderableFtlNoop.INSTANCE;
+        }
+
+        if (UtilValidate.isNotEmpty(subHyperlink.getWidth())) {
+            request.setAttribute("width", subHyperlink.getWidth());
+        }
+        if (UtilValidate.isNotEmpty(subHyperlink.getHeight())) {
+            request.setAttribute("height", subHyperlink.getHeight());
+        }
+
+        return makeHyperlinkByType(subHyperlink.getLinkType(), subHyperlink.getStyle(context),
+                subHyperlink.getUrlMode(), subHyperlink.getTarget(context),
+                subHyperlink.getParameterMap(context, subHyperlink.getModelFormField().getEntityName(),
+                        subHyperlink.getModelFormField().getServiceName()),
+                subHyperlink.getDescription(context), subHyperlink.getTargetWindow(context), "",
+                subHyperlink.getModelFormField(), request, response, context);
+    }
+
+    public RenderableFtl makeHyperlinkByType(String linkType, String linkStyle, String targetType, String target,
+                                             Map<String, String> parameterMap, String description, String targetWindow,
+                                             String confirmation, ModelFormField modelFormField,
+                                             HttpServletRequest request, HttpServletResponse response,
+                                             Map<String, Object> context) {
+        String realLinkType = WidgetWorker.determineAutoLinkType(linkType, target, targetType, request);
+        String encodedDescription = encode(description, modelFormField, context);
+        // get the parameterized pagination index and size fields
+        int paginatorNumber = WidgetWorker.getPaginatorNumber(context);
+        ModelForm modelForm = modelFormField.getModelForm();
+        ModelTheme modelTheme = visualTheme.getModelTheme();
+        String viewIndexField = modelForm.getMultiPaginateIndexField(context);
+        String viewSizeField = modelForm.getMultiPaginateSizeField(context);
+        int viewIndex = Paginator.getViewIndex(modelForm, context);
+        int viewSize = Paginator.getViewSize(modelForm, context);
+        if (("viewIndex" + "_" + paginatorNumber).equals(viewIndexField)) {
+            viewIndexField = "VIEW_INDEX" + "_" + paginatorNumber;
+        }
+        if (("viewSize" + "_" + paginatorNumber).equals(viewSizeField)) {
+            viewSizeField = "VIEW_SIZE" + "_" + paginatorNumber;
+        }
+        if ("hidden-form".equals(realLinkType)) {
+            parameterMap.put(viewIndexField, Integer.toString(viewIndex));
+            parameterMap.put(viewSizeField, Integer.toString(viewSize));
+
+            final RenderableFtlStringBuilder renderableFtlStringBuilder = RenderableFtlString.builder();
+            final StringBuilder htmlStringBuilder = renderableFtlStringBuilder.getStringBuilder();
+
+            if ("multi".equals(modelForm.getType())) {
+                final Element anchorElement = WidgetWorker.makeHiddenFormLinkAnchorElement(linkStyle,
+                        encodedDescription, confirmation, modelFormField, request, context);
+                htmlStringBuilder.append(anchorElement.outerHtml());
+
+                // this is a bit trickier, since we can't do a nested form we'll have to put the link to submit the
+                // form in place, but put the actual form def elsewhere, ie after the big form is closed
+                final RenderableFtlString postFormRenderableFtlString = RenderableFtlString.withStringBuilder(sb -> {
+                    final Element hiddenFormElement = WidgetWorker.makeHiddenFormLinkFormElement(target, targetType,
+                            targetWindow, parameterMap, modelFormField, request, response, context);
+                    sb.append(hiddenFormElement.outerHtml());
+                });
+                appendToPostFormRenderableFtl(postFormRenderableFtlString, context);
+
+            } else {
+                final Element hiddenFormElement = WidgetWorker.makeHiddenFormLinkFormElement(target, targetType,
+                        targetWindow, parameterMap, modelFormField, request, response, context);
+                htmlStringBuilder.append(hiddenFormElement.outerHtml());
+                final Element anchorElement = WidgetWorker.makeHiddenFormLinkAnchorElement(linkStyle,
+                        encodedDescription, confirmation, modelFormField, request, context);
+                htmlStringBuilder.append(anchorElement.outerHtml());
+            }
+
+            return renderableFtlStringBuilder.build();
+
+        } else {
+            if ("layered-modal".equals(realLinkType)) {
+                String uniqueItemName = "Modal_".concat(UUID.randomUUID().toString().replace("-", "_"));
+                String width = (String) request.getAttribute("width");
+                if (UtilValidate.isEmpty(width)) {
+                    width = String.valueOf(modelTheme.getLinkDefaultLayeredModalWidth());
+                    request.setAttribute("width", width);
+                }
+                String height = (String) request.getAttribute("height");
+                if (UtilValidate.isEmpty(height)) {
+                    height = String.valueOf(modelTheme.getLinkDefaultLayeredModalHeight());
+                    request.setAttribute("height", height);
+                }
+                request.setAttribute("uniqueItemName", uniqueItemName);
+                RenderableFtl renderableFtl = hyperlinkMacroCall(linkStyle, targetType, target, parameterMap,
+                        encodedDescription, confirmation, modelFormField, request, response, context, targetWindow);
+                request.removeAttribute("uniqueItemName");
+                request.removeAttribute("height");
+                request.removeAttribute("width");
+                return renderableFtl;
+            } else {
+                return hyperlinkMacroCall(linkStyle, targetType, target, parameterMap, encodedDescription, confirmation,
+                        modelFormField, request, response, context, targetWindow);
+            }
+        }
+    }
+
+    public RenderableFtl hyperlinkMacroCall(String linkStyle, String targetType, String target,
+                                            Map<String, String> parameterMap, String description, String confirmation,
+                                            ModelFormField modelFormField,
+                                            HttpServletRequest request, HttpServletResponse response,
+                                            Map<String, Object> context, String targetWindow) {
+        if (description != null || UtilValidate.isNotEmpty(request.getAttribute("image"))) {
+            StringBuilder linkUrl = new StringBuilder();
+            final URI linkUri = WidgetWorker.buildHyperlinkUri(target, targetType,
+                    UtilValidate.isEmpty(request.getAttribute("uniqueItemName")) ? parameterMap : null,
+                    null, false, false, true, request, response);
+            linkUrl.append(linkUri.toString());
+            String event = "";
+            String action = "";
+            String imgSrc = "";
+            String alt = "";
+            String id = "";
+            String uniqueItemName = "";
+            String width = "";
+            String height = "";
+            String imgTitle = "";
+            String hiddenFormName = WidgetWorker.makeLinkHiddenFormName(context, modelFormField);
+            if (UtilValidate.isNotEmpty(modelFormField.getEvent())
+                    && UtilValidate.isNotEmpty(modelFormField.getAction(context))) {
+                event = modelFormField.getEvent();
+                action = modelFormField.getAction(context);
+            }
+            if (UtilValidate.isNotEmpty(request.getAttribute("image"))) {
+                imgSrc = request.getAttribute("image").toString();
+            }
+            if (UtilValidate.isNotEmpty(request.getAttribute("alternate"))) {
+                alt = request.getAttribute("alternate").toString();
+            }
+            if (UtilValidate.isNotEmpty(request.getAttribute("imageTitle"))) {
+                imgTitle = request.getAttribute("imageTitle").toString();
+            }
+            Integer size = Integer.valueOf("0");
+            if (UtilValidate.isNotEmpty(request.getAttribute("descriptionSize"))) {
+                size = Integer.valueOf(request.getAttribute("descriptionSize").toString());
+            }
+            if (UtilValidate.isNotEmpty(description) && size > 0 && description.length() > size) {
+                imgTitle = description;
+                description = description.substring(0, size - 8) + "..."
+                        + description.substring(description.length() - 5);
+            }
+            if (UtilValidate.isEmpty(imgTitle)) {
+                imgTitle = modelFormField.getTitle(context);
+            }
+            if (UtilValidate.isNotEmpty(request.getAttribute("id"))) {
+                id = request.getAttribute("id").toString();
+            }
+            if (UtilValidate.isNotEmpty(request.getAttribute("uniqueItemName"))) {
+                uniqueItemName = request.getAttribute("uniqueItemName").toString();
+                width = request.getAttribute("width").toString();
+                height = request.getAttribute("height").toString();
+            }
+
+            return RenderableFtlMacroCall.builder()
+                    .name("makeHyperlinkString")
+                    .stringParameter("linkStyle", linkStyle == null ? "" : linkStyle)
+                    .stringParameter("hiddenFormName", hiddenFormName)
+                    .stringParameter("event", event)
+                    .stringParameter("action", action)
+                    .stringParameter("imgSrc", imgSrc)
+                    .stringParameter("title", imgTitle)
+                    .stringParameter("alternate", alt)
+                    .mapParameter("targetParameters", parameterMap)
+                    .stringParameter("linkUrl", linkUrl.toString())
+                    .stringParameter("targetWindow", targetWindow)
+                    .stringParameter("description", description)
+                    .stringParameter("confirmation", confirmation)
+                    .stringParameter("uniqueItemName", uniqueItemName)
+                    .stringParameter("height", height)
+                    .stringParameter("width", width)
+                    .stringParameter("id", id)
+                    .build();
+        } else {
+            return RenderableFtlNoop.INSTANCE;
+        }
+    }
+
+    public RenderableFtlMacroCall containerMacroCall(final Map<String, Object> context,
+                                                     final ContainerField containerField) {
+        final String id = containerField.getModelFormField().getCurrentContainerId(context);
+        String className = UtilFormatOut.checkNull(containerField.getModelFormField().getWidgetStyle());
+
+        return RenderableFtlMacroCall.builder()
+                .name("renderContainerField")
+                .stringParameter("id", id)
+                .stringParameter("className", className)
+                .build();
+    }
+
+    private String encode(String value, ModelFormField modelFormField, Map<String, Object> context) {
+        if (UtilValidate.isEmpty(value)) {
+            return value;
+        }
+        UtilCodec.SimpleEncoder encoder = (UtilCodec.SimpleEncoder) context.get("simpleEncoder");
+        if (modelFormField.getEncodeOutput() && encoder != null) {
+            value = encoder.encode(value);
+        } else {
+            value = internalEncoder.encode(value);
+        }
+        return value;
+    }
+
+    /**
+     * Create an ajaxXxxx JavaScript CSV string from a list of UpdateArea objects. See
+     * <code>OfbizUtil.js</code>.
+     *
+     * @param updateAreas
+     * @param extraParams Renderer-supplied additional target parameters
+     * @param context
+     * @return Parameter string or empty string if no UpdateArea objects were found
+     */
+    private String createAjaxParamsFromUpdateAreas(final List<ModelForm.UpdateArea> updateAreas,
+                                                   final String extraParams,
+                                                   final Map<String, Object> context) {
+        //FIXME copy from HtmlFormRenderer.java
+        if (updateAreas == null) {
+            return "";
+        }
+        String ajaxUrl = "";
+        boolean firstLoop = true;
+        for (ModelForm.UpdateArea updateArea : updateAreas) {
+            if (firstLoop) {
+                firstLoop = false;
+            } else {
+                ajaxUrl += ",";
+            }
+            Map<String, Object> ctx = UtilGenerics.cast(context);
+            Map<String, String> parameters = updateArea.getParameterMap(ctx);
+            String targetUrl = updateArea.getAreaTarget(context);
+            String ajaxParams;
+            StringBuffer ajaxParamsBuffer = new StringBuffer();
+            ajaxParamsBuffer.append(getAjaxParamsFromTarget(targetUrl));
+            //add first parameters from updateArea parameters
+            if (UtilValidate.isNotEmpty(parameters)) {
+                for (Map.Entry<String, String> entry : parameters.entrySet()) {
+                    String key = entry.getKey();
+                    String value = entry.getValue();
+                    //test if ajax parameters are not already into extraParams, if so do not add it
+                    if (UtilValidate.isNotEmpty(extraParams) && extraParams.contains(value)) {
+                        continue;
+                    }
+                    if (ajaxParamsBuffer.length() > 0 && ajaxParamsBuffer.indexOf(key) < 0) {
+                        ajaxParamsBuffer.append("&");
+                    }
+                    if (ajaxParamsBuffer.indexOf(key) < 0) {
+                        ajaxParamsBuffer.append(key).append("=").append(value);
+                    }
+                }
+            }
+            //then add parameters from request. Those parameters could end with an anchor so we must set ajax parameters first
+            if (UtilValidate.isNotEmpty(extraParams)) {
+                if (ajaxParamsBuffer.length() > 0 && !extraParams.startsWith("&")) {
+                    ajaxParamsBuffer.append("&");
+                }
+                ajaxParamsBuffer.append(extraParams);
+            }
+            ajaxParams = ajaxParamsBuffer.toString();
+            ajaxUrl += updateArea.getAreaId() + ",";
+            ajaxUrl += requestHandler.makeLink(request, response, UtilHttp.removeQueryStringFromTarget(targetUrl));
+            ajaxUrl += "," + ajaxParams;
+        }
+        Locale locale = UtilMisc.ensureLocale(context.get("locale"));
+        return FlexibleStringExpander.expandString(ajaxUrl, context, locale);
+    }
+
+    private void appendToPostFormRenderableFtl(final RenderableFtl renderableFtl, final Map<String, Object> context) {
+        // If there is already a Post Form RenderableFtl, wrap it in a sequence with the given RenderableFtl
+        // appended. This ensures we don't overwrite any other elements to be rendered after the main form.
+        final RenderableFtl current = getPostMultiFormRenderableFtl(context);
+
+        if (current == null) {
+            setPostMultiFormRenderableFtl(renderableFtl, context);
+        } else {
+            final RenderableFtlSequence wrapper = RenderableFtlSequence.builder()
+                    .renderableFtl(current)
+                    .renderableFtl(renderableFtl)
+                    .build();
+            setPostMultiFormRenderableFtl(wrapper, context);
+        }
+
+        final Map<String, Object> wholeFormContext = UtilGenerics.cast(context.get("wholeFormContext"));
+    }
+
+    private RenderableFtl getPostMultiFormRenderableFtl(final Map<String, Object> context) {
+        final Map<String, Object> wholeFormContext = getWholeFormContext(context);
+        return (RenderableFtl) wholeFormContext.get("postMultiFormRenderableFtl");
+    }
+
+    private void setPostMultiFormRenderableFtl(final RenderableFtl postMultiFormRenderableFtl,
+                                               final Map<String, Object> context) {
+        final Map<String, Object> wholeFormContext = getWholeFormContext(context);
+        wholeFormContext.put("postMultiFormRenderableFtl", postMultiFormRenderableFtl);
+    }
+
+    private Map<String, Object> getWholeFormContext(final Map<String, Object> context) {
+        final Map<String, Object> wholeFormContext = UtilGenerics.cast(context.get("wholeFormContext"));
+        if (wholeFormContext == null) {
+            throw new RuntimeException("Cannot access whole form context");
+        }
+        return wholeFormContext;
+    }
+
+    /**
+     * Extracts parameters from a target URL string, prepares them for an Ajax
+     * JavaScript call. This method is currently set to return a parameter string
+     * suitable for the Prototype.js library.
+     *
+     * @param target Target URL string
+     * @return Parameter string
+     */
+    private static String getAjaxParamsFromTarget(String target) {
+        String targetParams = UtilHttp.getQueryStringFromTarget(target);
+        targetParams = targetParams.replace("?", "");
+        targetParams = targetParams.replace("&amp;", "&");
+        return targetParams;
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterBooleanValue.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterBooleanValue.java
new file mode 100644
index 0000000..3b6d0c3
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterBooleanValue.java
@@ -0,0 +1,36 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.parameter;
+
+public final class MacroCallParameterBooleanValue implements MacroCallParameterValue {
+    private final boolean value;
+
+    public MacroCallParameterBooleanValue(boolean value) {
+        this.value = value;
+    }
+
+    public boolean isValue() {
+        return value;
+    }
+
+    @Override
+    public String toFtlString() {
+        return Boolean.toString(value);
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterMapValue.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterMapValue.java
new file mode 100644
index 0000000..6b5f207
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterMapValue.java
@@ -0,0 +1,48 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.parameter;
+
+import org.apache.ofbiz.base.util.UtilValidate;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public final class MacroCallParameterMapValue implements MacroCallParameterValue {
+    private final Map<String, String> value;
+
+    public MacroCallParameterMapValue(Map<String, String> value) {
+        this.value = value;
+    }
+
+    public Map<String, String> getValue() {
+        return value;
+    }
+
+    @Override
+    public String toFtlString() {
+        if (UtilValidate.isNotEmpty(value)) {
+            return value.entrySet()
+                    .stream()
+                    .map(entry -> "'" + entry.getKey() + "':'" + entry.getValue() + "'")
+                    .collect(Collectors.joining(",", "\"{", "}\""));
+        } else {
+            return "\"\"";
+        }
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterStringValue.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterStringValue.java
new file mode 100644
index 0000000..1c0aaff
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterStringValue.java
@@ -0,0 +1,36 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.parameter;
+
+public final class MacroCallParameterStringValue implements MacroCallParameterValue {
+    private final String value;
+
+    public MacroCallParameterStringValue(String value) {
+        this.value = value;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    @Override
+    public String toFtlString() {
+        return "\"" + value.replaceAll("\"", "\\\\\"") + "\"";
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterValue.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterValue.java
new file mode 100644
index 0000000..46bc1db
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/parameter/MacroCallParameterValue.java
@@ -0,0 +1,23 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.parameter;
+
+public interface MacroCallParameterValue {
+    String toFtlString();
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtl.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtl.java
new file mode 100644
index 0000000..4b55ae4
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtl.java
@@ -0,0 +1,26 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.renderable;
+
+/**
+ * Component that can be rendered to an FTL (FreeMarker Template Language) string.
+ */
+public interface RenderableFtl {
+    String toFtlString();
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlMacroCall.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlMacroCall.java
new file mode 100644
index 0000000..03af7c4
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlMacroCall.java
@@ -0,0 +1,102 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.renderable;
+
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterBooleanValue;
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterMapValue;
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterStringValue;
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterValue;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Represents an FTL macro call.
+ */
+public final class RenderableFtlMacroCall implements RenderableFtl {
+    private final String name;
+    private final Map<String, MacroCallParameterValue> parameters;
+
+    private RenderableFtlMacroCall(String name, Map<String, MacroCallParameterValue> parameters) {
+        if (name == null) {
+            throw new NullPointerException("RenderableFtlMacroCall name cannot be null");
+        }
+        this.name = name;
+        this.parameters = parameters;
+    }
+
+    @Override
+    public String toFtlString() {
+        return parameters.entrySet()
+                .stream()
+                .map((entry) -> createFtlMacroParameter(entry.getKey(), entry.getValue()))
+                .collect(Collectors.joining(" ", "<@" + name + " ", " />"));
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Map<String, MacroCallParameterValue> getParameters() {
+        return parameters;
+    }
+
+    private String createFtlMacroParameter(final String parameterName, final MacroCallParameterValue parameterValue) {
+        return parameterName + "=" + parameterValue.toFtlString();
+    }
+
+    public static RenderableFtlMacroCallBuilder builder() {
+        return new RenderableFtlMacroCallBuilder();
+    }
+
+    public static final class RenderableFtlMacroCallBuilder {
+        private String name;
+        private Map<String, MacroCallParameterValue> parameters = new HashMap<>();
+
+        private RenderableFtlMacroCallBuilder() {
+        }
+
+        public RenderableFtlMacroCallBuilder name(final String name) {
+            this.name = name;
+            return this;
+        }
+
+        public RenderableFtlMacroCallBuilder stringParameter(final String parameterName, final String parameterValue) {
+            return parameter(parameterName, new MacroCallParameterStringValue(parameterValue));
+        }
+
+        public RenderableFtlMacroCallBuilder booleanParameter(final String parameterName, final boolean parameterValue) {
+            return parameter(parameterName, new MacroCallParameterBooleanValue(parameterValue));
+        }
+
+        public RenderableFtlMacroCallBuilder mapParameter(final String parameterName, final Map<String, String> parameterValue) {
+            return parameter(parameterName, new MacroCallParameterMapValue(parameterValue));
+        }
+
+        public RenderableFtlMacroCall build() {
+            return new RenderableFtlMacroCall(name, parameters);
+        }
+
+        private RenderableFtlMacroCallBuilder parameter(final String name, final MacroCallParameterValue value) {
+            parameters.put(name, value);
+            return this;
+        }
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlNoop.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlNoop.java
new file mode 100644
index 0000000..a0d3ca8
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlNoop.java
@@ -0,0 +1,31 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.renderable;
+
+public final class RenderableFtlNoop implements RenderableFtl {
+    public static final RenderableFtlNoop INSTANCE = new RenderableFtlNoop();
+
+    private RenderableFtlNoop() {
+    }
+
+    @Override
+    public String toFtlString() {
+        return "";
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlSequence.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlSequence.java
new file mode 100644
index 0000000..a575c43
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlSequence.java
@@ -0,0 +1,58 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.renderable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Class for composing multiple RenderableFtl objects in a sequence.
+ */
+public final class RenderableFtlSequence implements RenderableFtl {
+    private final List<RenderableFtl> renderableFtls;
+
+    private RenderableFtlSequence(List<RenderableFtl> renderableFtls) {
+        this.renderableFtls = renderableFtls;
+    }
+
+    @Override
+    public String toFtlString() {
+        return renderableFtls.stream()
+                .map(RenderableFtl::toFtlString)
+                .collect(Collectors.joining());
+    }
+
+    public static RenderableFtlSequenceBuilder builder() {
+        return new RenderableFtlSequenceBuilder();
+    }
+
+    public static final class RenderableFtlSequenceBuilder {
+        private List<RenderableFtl> renderableFtls = new ArrayList<>();
+
+        public RenderableFtlSequenceBuilder renderableFtl(RenderableFtl renderableFtl) {
+            renderableFtls.add(renderableFtl);
+            return this;
+        }
+
+        public RenderableFtlSequence build() {
+            return new RenderableFtlSequence(renderableFtls);
+        }
+    }
+}
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlString.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlString.java
new file mode 100644
index 0000000..4cead28
--- /dev/null
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/renderable/RenderableFtlString.java
@@ -0,0 +1,67 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro.renderable;
+
+import java.util.function.Consumer;
+
+public final class RenderableFtlString implements RenderableFtl {
+    private final String ftlString;
+
+    public RenderableFtlString(final String ftlString) {
+        this.ftlString = ftlString;
+    }
+
+    public String getFtlString() {
+        return ftlString;
+    }
+
+    public static RenderableFtlStringBuilder builder() {
+        return new RenderableFtlStringBuilder();
+    }
+
+    public static RenderableFtlString withStringBuilder(final Consumer<StringBuilder> callback) {
+        final RenderableFtlStringBuilder builder = builder();
+        callback.accept(builder.getStringBuilder());
+        return builder.build();
+    }
+
+    @Override
+    public String toString() {
+        return "RenderableFtlString{"
+                + "ftlString='" + ftlString + '\''
+                + '}';
+    }
+
+    @Override
+    public String toFtlString() {
+        return ftlString;
+    }
+
+    public static final class RenderableFtlStringBuilder {
+        private final StringBuilder stringBuilder = new StringBuilder();
+
+        public StringBuilder getStringBuilder() {
+            return stringBuilder;
+        }
+
+        public RenderableFtlString build() {
+            return new RenderableFtlString(stringBuilder.toString());
+        }
+    }
+}
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallMatcher.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallMatcher.java
new file mode 100644
index 0000000..647886f
--- /dev/null
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallMatcher.java
@@ -0,0 +1,100 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtl;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlMacroCall;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class MacroCallMatcher extends TypeSafeMatcher<RenderableFtl> {
+    private final String macroName;
+    private final MacroCallParameterMatcher[] parameterMatchers;
+
+    private final List<MacroCallParameterMatcher> failedParameterMatchers = new ArrayList<>();
+
+    public MacroCallMatcher(final String macroName, final MacroCallParameterMatcher... parameterMatchers) {
+        super(RenderableFtlMacroCall.class);
+
+        this.macroName = macroName;
+        this.parameterMatchers = parameterMatchers;
+    }
+
+    @Override
+    protected boolean matchesSafely(final RenderableFtl item) {
+        final RenderableFtlMacroCall macroCall = (RenderableFtlMacroCall) item;
+        boolean nameMatched = (macroName == null) || macroName.equals(macroCall.getName());
+
+        for (final MacroCallParameterMatcher parameterMatcher : parameterMatchers) {
+            boolean matchForParameterMatcher = macroCall.getParameters()
+                    .entrySet()
+                    .stream()
+                    .anyMatch(parameterMatcher::matches);
+            if (!matchForParameterMatcher) {
+                failedParameterMatchers.add(parameterMatcher);
+            }
+        }
+
+        return nameMatched && failedParameterMatchers.isEmpty();
+    }
+
+    @Override
+    public void describeTo(final Description description) {
+        description.appendText("MacroCall has name '" + macroName + "' ");
+        description.appendText("with Parameters[");
+        for (final MacroCallParameterMatcher parameterMatcher : parameterMatchers) {
+            parameterMatcher.describeTo(description);
+        }
+        description.appendText("]");
+    }
+
+    @Override
+    protected void describeMismatchSafely(final RenderableFtl item, final Description mismatchDescription) {
+        final RenderableFtlMacroCall macroCall = (RenderableFtlMacroCall) item;
+
+        mismatchDescription.appendText("MacroCall has name '" + macroCall.getName() + "' ");
+
+        if (!failedParameterMatchers.isEmpty()) {
+            mismatchDescription.appendText("with Parameters[");
+            for (final MacroCallParameterMatcher failedParameterMatcher : failedParameterMatchers) {
+                macroCall.getParameters()
+                        .entrySet()
+                        .forEach(entry ->
+                                failedParameterMatcher.describeMismatch(entry, mismatchDescription));
+            }
+            mismatchDescription.appendText("]");
+        }
+    }
+
+    public static MacroCallMatcher hasName(final String macroName) {
+        return new MacroCallMatcher(macroName);
+    }
+
+    public static MacroCallMatcher hasParameters(final MacroCallParameterMatcher... parameterMatchers) {
+        return new MacroCallMatcher(null, parameterMatchers);
+    }
+
+    public static MacroCallMatcher hasNameAndParameters(final String macroName,
+                                                        final MacroCallParameterMatcher... parameterMatchers) {
+        return new MacroCallMatcher(macroName, parameterMatchers);
+    }
+}
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterBooleanValueMatcher.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterBooleanValueMatcher.java
new file mode 100644
index 0000000..ceaf9cc6
--- /dev/null
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterBooleanValueMatcher.java
@@ -0,0 +1,50 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterBooleanValue;
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterValue;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+
+public final class MacroCallParameterBooleanValueMatcher extends TypeSafeMatcher<MacroCallParameterValue> {
+    private final boolean value;
+
+    public MacroCallParameterBooleanValueMatcher(final boolean value) {
+        super(MacroCallParameterBooleanValue.class);
+        this.value = value;
+    }
+
+    @Override
+    protected boolean matchesSafely(final MacroCallParameterValue item) {
+        final MacroCallParameterBooleanValue booleanValue = (MacroCallParameterBooleanValue) item;
+        return value == booleanValue.isValue();
+    }
+
+    @Override
+    public void describeTo(final Description description) {
+        description.appendText("with boolean value '" + value + "'");
+    }
+
+    @Override
+    protected void describeMismatchSafely(final MacroCallParameterValue item, final Description mismatchDescription) {
+        final MacroCallParameterBooleanValue booleanValue = (MacroCallParameterBooleanValue) item;
+        mismatchDescription.appendText("with boolean value '" + booleanValue.isValue() + "'");
+    }
+}
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterMapValueMatcher.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterMapValueMatcher.java
new file mode 100644
index 0000000..90cb099
--- /dev/null
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterMapValueMatcher.java
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterMapValue;
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterValue;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+import java.util.Map;
+
+public final class MacroCallParameterMapValueMatcher extends TypeSafeMatcher<MacroCallParameterValue> {
+    private final Matcher<Map<String, String>> matcher;
+
+    public MacroCallParameterMapValueMatcher(final Matcher<Map<String, String>> matcher) {
+        super(MacroCallParameterMapValue.class);
+        this.matcher = matcher;
+    }
+
+    @Override
+    protected boolean matchesSafely(final MacroCallParameterValue item) {
+        final MacroCallParameterMapValue mapValue = (MacroCallParameterMapValue) item;
+        return matcher.matches(mapValue.getValue());
+    }
+
+    @Override
+    public void describeTo(final Description description) {
+        description.appendText("with map value '");
+        matcher.describeTo(description);
+        description.appendText("' ");
+    }
+
+    @Override
+    protected void describeMismatchSafely(final MacroCallParameterValue item, final Description mismatchDescription) {
+        final MacroCallParameterMapValue mapValue = (MacroCallParameterMapValue) item;
+        mismatchDescription.appendText("with map value '");
+        matcher.describeMismatch(mapValue, mismatchDescription);
+        mismatchDescription.appendText("' ");
+    }
+}
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterMatcher.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterMatcher.java
new file mode 100644
index 0000000..2018c7c
--- /dev/null
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterMatcher.java
@@ -0,0 +1,90 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterValue;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+import java.util.Map;
+
+public final class MacroCallParameterMatcher extends TypeSafeMatcher<Map.Entry<String, MacroCallParameterValue>> {
+    private final String name;
+    private final Matcher<MacroCallParameterValue> valueMatcher;
+
+    private boolean nameMatches = true;
+    private boolean valueMatches = true;
+
+    public MacroCallParameterMatcher(final String name, final Matcher<MacroCallParameterValue> valueMatcher) {
+        this.name = name;
+        this.valueMatcher = valueMatcher;
+    }
+
+    @Override
+    protected boolean matchesSafely(final Map.Entry<String, MacroCallParameterValue> item) {
+        if (name != null) {
+            nameMatches = name.equals(item.getKey());
+        }
+
+        if (valueMatcher != null) {
+            valueMatches = valueMatcher.matches(item.getValue());
+        }
+
+        return nameMatches && valueMatches;
+    }
+
+    @Override
+    public void describeTo(final Description description) {
+        if (name != null) {
+            description.appendText("has name '" + name + "' ");
+        }
+
+        if (valueMatcher != null) {
+            valueMatcher.describeTo(description);
+        }
+    }
+
+    @Override
+    protected void describeMismatchSafely(final Map.Entry<String, MacroCallParameterValue> item,
+                                          final Description mismatchDescription) {
+        if (name != null) {
+            mismatchDescription.appendText("has name '" + item.getKey() + "' ");
+        }
+
+        if (valueMatcher != null) {
+            valueMatcher.describeMismatch(item.getValue(), mismatchDescription);
+        }
+
+        mismatchDescription.appendText(", ");
+    }
+
+    public static MacroCallParameterMatcher hasName(final String name) {
+        return new MacroCallParameterMatcher(name, null);
+    }
+
+    public static MacroCallParameterMatcher hasNameAndStringValue(final String name, final String value) {
+        return new MacroCallParameterMatcher(name, new MacroCallParameterStringValueMatcher(value));
+    }
+
+    public static MacroCallParameterMatcher hasNameAndMapValue(final String name,
+                                                               final Matcher<Map<String, String>> matcher) {
+        return new MacroCallParameterMatcher(name, new MacroCallParameterMapValueMatcher(matcher));
+    }
+}
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterStringValueMatcher.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterStringValueMatcher.java
new file mode 100644
index 0000000..735c02f
--- /dev/null
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroCallParameterStringValueMatcher.java
@@ -0,0 +1,50 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterStringValue;
+import org.apache.ofbiz.widget.renderer.macro.parameter.MacroCallParameterValue;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+
+public final class MacroCallParameterStringValueMatcher extends TypeSafeMatcher<MacroCallParameterValue> {
+    private final String value;
+
+    public MacroCallParameterStringValueMatcher(final String value) {
+        super(MacroCallParameterStringValue.class);
+        this.value = value;
+    }
+
+    @Override
+    protected boolean matchesSafely(final MacroCallParameterValue item) {
+        final MacroCallParameterStringValue stringValue = (MacroCallParameterStringValue) item;
+        return value.equals(stringValue.getValue());
+    }
+
+    @Override
+    public void describeTo(final Description description) {
+        description.appendText("with string value '" + value + "'");
+    }
+
+    @Override
+    protected void describeMismatchSafely(final MacroCallParameterValue item, final Description mismatchDescription) {
+        final MacroCallParameterStringValue stringValue = (MacroCallParameterStringValue) item;
+        mismatchDescription.appendText("with string value '" + stringValue.getValue() + "'");
+    }
+}
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRendererTest.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRendererTest.java
index d7f197e..68afb74 100644
--- a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRendererTest.java
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/MacroFormRendererTest.java
@@ -18,55 +18,53 @@
  *******************************************************************************/
 package org.apache.ofbiz.widget.renderer.macro;
 
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.empty;
-import static org.hamcrest.Matchers.not;
-import static org.hamcrest.Matchers.startsWith;
-
-import java.io.IOException;
-import java.io.StringReader;
-import java.io.StringWriter;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
-
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import freemarker.core.Environment;
+import freemarker.template.Template;
+import mockit.Expectations;
+import mockit.Injectable;
+import mockit.Mock;
+import mockit.MockUp;
+import mockit.Mocked;
+import mockit.Tested;
+import mockit.Verifications;
 import org.apache.ofbiz.base.util.UtilCodec.SimpleEncoder;
 import org.apache.ofbiz.base.util.UtilHttp;
 import org.apache.ofbiz.base.util.UtilProperties;
 import org.apache.ofbiz.base.util.template.FreeMarkerWorker;
 import org.apache.ofbiz.entity.Delegator;
-import org.apache.ofbiz.webapp.control.ConfigXMLReader;
 import org.apache.ofbiz.webapp.control.RequestHandler;
+import org.apache.ofbiz.widget.model.FieldInfo;
 import org.apache.ofbiz.widget.model.ModelForm;
 import org.apache.ofbiz.widget.model.ModelFormField;
 import org.apache.ofbiz.widget.model.ModelScreenWidget;
 import org.apache.ofbiz.widget.model.ModelSingleForm;
 import org.apache.ofbiz.widget.model.ThemeFactory;
 import org.apache.ofbiz.widget.renderer.VisualTheme;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtl;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlMacroCall;
 import org.hamcrest.Matchers;
 import org.junit.Before;
 import org.junit.Test;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
 
-import freemarker.core.Environment;
-import freemarker.template.Template;
-import mockit.Expectations;
-import mockit.Injectable;
-import mockit.Mock;
-import mockit.MockUp;
-import mockit.Mocked;
-import mockit.Tested;
-import mockit.Verifications;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.startsWith;
 
 public class MacroFormRendererTest {
 
@@ -79,6 +77,9 @@ public class MacroFormRendererTest {
     @Injectable
     private FtlWriter ftlWriter;
 
+    @Injectable
+    private RenderableFtlFormElementsBuilder renderableFtlFormElementsBuilder;
+
     @Mocked
     private HttpSession httpSession;
 
@@ -100,14 +101,23 @@ public class MacroFormRendererTest {
     @Mocked
     private ModelFormField modelFormField;
 
-    private final StringWriter appendable = new StringWriter();
-
     @Injectable
     private String macroLibraryPath = null;
 
     @Tested
     private MacroFormRenderer macroFormRenderer;
 
+    private final StringWriter appendable = new StringWriter();
+    private RenderableFtlMacroCall genericMacroCall = RenderableFtlMacroCall.builder()
+            .name("genericTest")
+            .build();
+    private RenderableFtlMacroCall genericHyperlinkMacroCall = RenderableFtlMacroCall.builder()
+            .name("genericHyperlink")
+            .build();
+    private RenderableFtlMacroCall genericTooltipMacroCall = RenderableFtlMacroCall.builder()
+            .name("genericTooltip")
+            .build();
+
     @Before
     public void setupMockups() {
         new FreeMarkerWorkerMockUp();
@@ -117,144 +127,95 @@ public class MacroFormRendererTest {
         new UtilPropertiesMockUp();
     }
 
-    @Test
-    public void emptyLabelNotRendered(@Mocked ModelScreenWidget.Label label) {
-        new Expectations() {
-            {
-                label.getText(withNotNull());
-                result = "";
-
-                ftlWriter.executeMacro(withNotNull(), withNull(), withNotNull());
-                times = 0;
-            }
-        };
-
-        macroFormRenderer.renderLabel(appendable, ImmutableMap.of(), label);
-    }
-
     @SuppressWarnings("checkstyle:InnerAssignment")
     @Test
-    public void labelMacroRenderedWithText(@Mocked ModelScreenWidget.Label label) throws IOException {
+    public void labelRenderedAsSingleMacro(@Mocked ModelScreenWidget.Label label) {
         new Expectations() {
             {
-                label.getText(withNotNull());
-                result = "TEXT";
+                renderableFtlFormElementsBuilder.label(withNotNull(), withNotNull());
+                result = genericMacroCall;
             }
         };
 
         macroFormRenderer.renderLabel(appendable, ImmutableMap.of(), label);
-
-        assertAndGetMacroString("renderLabel", ImmutableMap.of("text", "TEXT"));
+        genericSingleMacroRenderedVerification();
     }
 
     @Test
-    public void displayFieldMacroRendered(@Mocked ModelFormField.DisplayField displayField) throws IOException {
+    public void displayFieldRendersFieldWithTooltip(@Mocked ModelFormField.DisplayField displayField) {
         new Expectations() {
             {
-                displayField.getType();
-                result = "TYPE";
-
-                displayField.getDescription(withNotNull());
-                result = "DESCRIPTION";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "TOOLTIP";
+                renderableFtlFormElementsBuilder.displayField(withNotNull(), withNotNull(), anyBoolean);
+                result = genericMacroCall;
             }
         };
+        genericTooltipRenderedExpectation(displayField);
 
         macroFormRenderer.renderDisplayField(appendable, ImmutableMap.of(), displayField);
 
-        assertAndGetMacroString("renderDisplayField", ImmutableMap.of("type", "TYPE"));
+        genericSingleMacroRenderedVerification();
+        genericTooltipRenderedVerification();
     }
 
     @Test
-    public void displayEntityFieldMacroRenderedWithLink(@Mocked ModelFormField.DisplayEntityField displayEntityField,
-                                                        @Mocked ModelFormField.SubHyperlink subHyperlink)
-            throws IOException {
-
-        final Map<String, ConfigXMLReader.RequestMap> requestMapMap = new HashMap<>();
-
+    public void displayEntityFieldRendersFieldWithLinkAndTooltip(
+            @Mocked ModelFormField.DisplayEntityField displayEntityField,
+            @Mocked ModelFormField.SubHyperlink subHyperlink) {
         new Expectations() {
             {
-                displayEntityField.getType();
-                result = "TYPE";
-
-                displayEntityField.getDescription(withNotNull());
-                result = "DESCRIPTION";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "TOOLTIP";
+                renderableFtlFormElementsBuilder.displayField(withNotNull(), withNotNull(), anyBoolean);
+                result = genericMacroCall;
 
                 displayEntityField.getSubHyperlink();
                 result = subHyperlink;
 
-                subHyperlink.getStyle(withNotNull());
-                result = "TestLinkStyle";
-
-                subHyperlink.getUrlMode();
-                result = "url-mode";
-
-                subHyperlink.shouldUse(withNotNull());
-                result = true;
-
-                subHyperlink.getDescription(withNotNull());
-                result = "LinkDescription";
-
-                subHyperlink.getTarget(withNotNull());
-                result = "/link/target/path";
-
-                request.getAttribute("requestMapMap");
-                result = requestMapMap;
+                renderableFtlFormElementsBuilder.makeHyperlinkString(subHyperlink, withNotNull());
+                result = genericHyperlinkMacroCall;
             }
         };
+        genericTooltipRenderedExpectation(displayEntityField);
 
-        Map<String, Object> context = new HashMap<>();
-        macroFormRenderer.renderDisplayField(appendable, context, displayEntityField);
+        macroFormRenderer.renderDisplayField(appendable, ImmutableMap.of(), displayEntityField);
 
-        System.out.println(appendable.toString());
-        assertAndGetMacroString("renderDisplayField", ImmutableMap.of("type", "TYPE"));
+        genericSingleMacroRenderedVerification();
+        genericSubHyperlinkRenderedVerification();
+        genericTooltipRenderedVerification();
     }
 
     @Test
-    public void textFieldMacroRendered(@Mocked ModelFormField.TextField textField) throws IOException {
+    public void textFieldRendersFieldWithLinkAndTooltip(@Mocked final ModelFormField.TextField textField,
+                                                        @Mocked final ModelFormField.SubHyperlink subHyperlink) {
+        final RenderableFtl renderableFtlAsterisk = RenderableFtlMacroCall.builder()
+                .name("asterisks")
+                .build();
         new Expectations() {
             {
-                httpSession.getAttribute("delegatorName");
-                result = "delegator";
+                renderableFtlFormElementsBuilder.textField(withNotNull(), textField, anyBoolean);
+                result = genericMacroCall;
 
-                modelFormField.getEntry(withNotNull(), anyString);
-                result = "TEXTVALUE";
+                textField.getSubHyperlink();
+                result = subHyperlink;
+
+                renderableFtlFormElementsBuilder.makeHyperlinkString(subHyperlink, withNotNull());
+                result = genericHyperlinkMacroCall;
 
-                modelFormField.getTooltip(withNotNull());
-                result = "";
+                renderableFtlFormElementsBuilder.asterisks(withNotNull(), withNotNull());
+                result = renderableFtlAsterisk;
             }
         };
 
-        macroFormRenderer.renderTextField(appendable, ImmutableMap.of("session", httpSession), textField);
+        genericTooltipRenderedExpectation(textField);
 
-        assertAndGetMacroString("renderTextField", ImmutableMap.of("value", "TEXTVALUE"));
-    }
-
-    @Test
-    public void textRendererUsesContainerId(@Mocked ModelFormField.TextField textField)
-            throws IOException {
+        macroFormRenderer.renderTextField(appendable, ImmutableMap.of("session", httpSession), textField);
+        genericSingleMacroRenderedVerification();
+        genericSubHyperlinkRenderedVerification();
+        genericTooltipRenderedVerification();
 
-        new Expectations() {
+        new Verifications() {
             {
-                httpSession.getAttribute("delegatorName");
-                result = "delegator";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
-
-                modelFormField.getCurrentContainerId(withNotNull());
-                result = "CurrentTextId";
-
-                new StringReader(withSubstring("id=\"CurrentTextId\""));
+                ftlWriter.processFtl(appendable, renderableFtlAsterisk);
             }
         };
-
-        macroFormRenderer.renderTextField(appendable, ImmutableMap.of("session", httpSession), textField);
     }
 
     @Test
@@ -269,9 +230,6 @@ public class MacroFormRendererTest {
 
                 textareaField.getRows();
                 result = 22;
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -292,9 +250,6 @@ public class MacroFormRendererTest {
 
                 dateTimeField.getInputMethod();
                 result = "date";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -315,9 +270,6 @@ public class MacroFormRendererTest {
 
                 dropDownField.getAllOptionValues(withNotNull(), (Delegator) any);
                 result = optionValues;
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -341,9 +293,6 @@ public class MacroFormRendererTest {
 
                 checkField.getAllOptionValues(withNotNull(), (Delegator) any);
                 result = optionValues;
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -367,9 +316,6 @@ public class MacroFormRendererTest {
 
                 radioField.getAllOptionValues(withNotNull(), (Delegator) any);
                 result = optionValues;
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -386,9 +332,6 @@ public class MacroFormRendererTest {
             {
                 modelFormField.getTitle(withNotNull());
                 result = "BUTTONTITLE";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -402,9 +345,6 @@ public class MacroFormRendererTest {
             {
                 modelFormField.getTitle(withNotNull());
                 result = "BUTTONTITLE";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -604,9 +544,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getParameterName(withNotNull());
                 result = "FIELDNAME";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -643,9 +580,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getParameterName(withNotNull());
                 result = "FIELDNAME";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -672,9 +606,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getParameterName(withNotNull());
                 result = "FIELDNAME";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -704,9 +635,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getCurrentContainerId(withNotNull());
                 result = "CONTAINERID";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "";
             }
         };
 
@@ -759,9 +687,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getWidgetStyle();
                 result = "WIDGETSTYLE";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "TOOLTIP";
             }
         };
 
@@ -789,9 +714,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getWidgetStyle();
                 result = "WIDGETSTYLE";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "TOOLTIP";
             }
         };
 
@@ -813,9 +735,6 @@ public class MacroFormRendererTest {
 
                 modelFormField.getEntry(withNotNull(), null);
                 result = "VALUE";
-
-                modelFormField.getTooltip(withNotNull());
-                result = "TOOLTIP";
             }
         };
 
@@ -896,57 +815,16 @@ public class MacroFormRendererTest {
     }
 
     @Test
-    public void tooltipMacroRendered() throws IOException {
+    public void containerRendererAsSingleMacro() throws IOException {
         new Expectations() {
             {
-                modelFormField.getTooltip(withNotNull());
-                result = "TOOLTIP\"With\"Quotes";
-
-                modelFormField.getTooltipStyle();
-                result = "TOOLTIPSTYLE";
-            }
-        };
-
-        final Map<String, Object> context = new HashMap<>();
-        macroFormRenderer.appendTooltip(appendable, context, modelFormField);
-
-        assertAndGetMacroString("renderTooltip", ImmutableMap.of(
-                "tooltip", "TOOLTIP\\\"With\\\"Quotes",
-                "tooltipStyle", "TOOLTIPSTYLE"));
-    }
-
-    @Test
-    public void asterisksMacroRendered() throws IOException {
-        new Expectations() {
-            {
-                modelFormField.getRequiredField();
-                result = true;
-
-                modelFormField.getRequiredFieldStyle();
-                result = "REQUIREDSTYLE";
-            }
-        };
-
-        final Map<String, Object> context = new HashMap<>();
-        macroFormRenderer.addAsterisks(appendable, context, modelFormField);
-
-        assertAndGetMacroString("renderAsterisks", ImmutableMap.of(
-                "requiredField", "true",
-                "requiredStyle", "REQUIREDSTYLE"));
-    }
-
-    @Test
-    public void containerRendererUsesContainerId() throws IOException {
-        new Expectations() {
-            {
-                modelFormField.getCurrentContainerId(withNotNull());
-                result = "CurrentContainerId";
-
-                new StringReader(withSubstring("id=\"CurrentContainerId\""));
+                renderableFtlFormElementsBuilder.containerMacroCall(withNotNull(), withNotNull());
+                result = genericMacroCall;
             }
         };
 
         macroFormRenderer.renderContainerFindField(appendable, ImmutableMap.of(), containerField);
+        genericSingleMacroRenderedVerification();
     }
 
     /**
@@ -1039,7 +917,7 @@ public class MacroFormRendererTest {
         new Verifications() {
             {
                 List<String> macros = new ArrayList<>();
-                ftlWriter.executeMacro(withNotNull(), withNull(), withCapture(macros));
+                ftlWriter.processFtlString(withNotNull(), withNull(), withCapture(macros));
 
                 assertThat(macros, not(empty()));
                 final String macro = macros.get(0);
@@ -1072,6 +950,68 @@ public class MacroFormRendererTest {
         }
     }
 
+    /**
+     * Assert that the generic MacroCall instance is passed to the macro executor. This is used for simple renderings
+     * where MacroFormRenderer has FormMacroCallBuilder to construct a MacroCall and then passes it straight to the
+     * MacroCall executor.
+     */
+    private void genericSingleMacroRenderedVerification() {
+        new Verifications() {
+            {
+                ftlWriter.processFtl(appendable, genericMacroCall);
+            }
+        };
+    }
+
+    private void genericTooltipRenderedExpectation(final FieldInfo fieldInfo) {
+        new Expectations() {
+            {
+                fieldInfo.getModelFormField();
+                result = modelFormField;
+
+                renderableFtlFormElementsBuilder.tooltip(withNotNull(), modelFormField);
+                result = genericTooltipMacroCall;
+            }
+        };
+    }
+
+    private void genericTooltipRenderedVerification() {
+        new Verifications() {
+            {
+                ftlWriter.processFtl(appendable, genericTooltipMacroCall);
+            }
+        };
+    }
+
+    private void genericSubHyperlinkRenderedExpectation(final ModelFormField.SubHyperlink subHyperlink) {
+        new Expectations() {
+            {
+                subHyperlink.shouldUse(withNotNull());
+                result = true;
+
+                subHyperlink.getStyle(withNotNull());
+                result = "buttontext";
+
+                subHyperlink.getUrlMode();
+                result = "inter-app";
+
+                subHyperlink.getTarget(withNotNull());
+                result = "/path/to/target";
+
+                subHyperlink.getDescription(withNotNull());
+                result = "LinkDescription";
+            }
+        };
+    }
+
+    private void genericSubHyperlinkRenderedVerification() {
+        new Verifications() {
+            {
+                ftlWriter.processFtl(appendable, genericHyperlinkMacroCall);
+            }
+        };
+    }
+
     class FreeMarkerWorkerMockUp extends MockUp<FreeMarkerWorker> {
         @Mock
         public Template getTemplate(String templateLocation) {
diff --git a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/RenderableFtlFormElementsBuilderTest.java b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/RenderableFtlFormElementsBuilderTest.java
new file mode 100644
index 0000000..a516d49
--- /dev/null
+++ b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/macro/RenderableFtlFormElementsBuilderTest.java
@@ -0,0 +1,238 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.widget.renderer.macro;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import mockit.Expectations;
+import mockit.Injectable;
+import mockit.Mocked;
+import mockit.Tested;
+import org.apache.ofbiz.webapp.control.ConfigXMLReader;
+import org.apache.ofbiz.webapp.control.RequestHandler;
+import org.apache.ofbiz.widget.model.ModelForm;
+import org.apache.ofbiz.widget.model.ModelFormField;
+import org.apache.ofbiz.widget.model.ModelScreenWidget;
+import org.apache.ofbiz.widget.model.ModelTheme;
+import org.apache.ofbiz.widget.renderer.VisualTheme;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlMacroCall;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtl;
+import org.apache.ofbiz.widget.renderer.macro.renderable.RenderableFtlNoop;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+
+public class RenderableFtlFormElementsBuilderTest {
+
+    @Injectable
+    private VisualTheme visualTheme;
+
+    @Injectable
+    private RequestHandler requestHandler;
+
+    @Injectable
+    private HttpServletRequest request;
+
+    @Injectable
+    private HttpServletResponse response;
+
+    @Mocked
+    private HttpSession httpSession;
+
+    @Mocked
+    private ModelTheme modelTheme;
+
+    @Mocked
+    private ModelFormField.ContainerField containerField;
+
+    @Mocked
+    private ModelFormField modelFormField;
+
+    @Tested
+    private RenderableFtlFormElementsBuilder renderableFtlFormElementsBuilder;
+
+    @Test
+    public void emptyLabelUsesNoopMacro(@Mocked ModelScreenWidget.Label label) {
+        new Expectations() {
+            {
+                label.getText(withNotNull());
+                result = "";
+            }
+        };
+
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.label(ImmutableMap.of(), label);
+        assertThat(renderableFtl, equalTo(RenderableFtlNoop.INSTANCE));
+    }
+
+    @Test
+    public void labelMacroCallUsesText(@Mocked final ModelScreenWidget.Label label) {
+        new Expectations() {
+            {
+                label.getText(withNotNull());
+                result = "TEXT";
+            }
+        };
+
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.label(ImmutableMap.of(), label);
+        assertThat(renderableFtl,
+                MacroCallMatcher.hasNameAndParameters("renderLabel",
+                        MacroCallParameterMatcher.hasNameAndStringValue("text", "TEXT")));
+    }
+
+    @Test
+    public void displayFieldMacroUsesType(@Mocked final ModelFormField.DisplayField displayField) {
+        new Expectations() {
+            {
+                displayField.getType();
+                result = "TYPE";
+
+                displayField.getDescription(withNotNull());
+                result = "DESCRIPTION";
+            }
+        };
+
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.displayField(ImmutableMap.of(),
+                displayField, false);
+        assertThat(renderableFtl,
+                MacroCallMatcher.hasNameAndParameters("renderDisplayField",
+                        MacroCallParameterMatcher.hasNameAndStringValue("type", "TYPE")));
+    }
+
+    @Test
+    public void containerMacroCallUsesContainerId() {
+        new Expectations() {
+            {
+                modelFormField.getCurrentContainerId(withNotNull());
+                result = "CurrentContainerId";
+            }
+        };
+
+        final RenderableFtlMacroCall macroCall = renderableFtlFormElementsBuilder.containerMacroCall(ImmutableMap.of(), containerField);
+        assertThat(macroCall,
+                MacroCallMatcher.hasNameAndParameters("renderContainerField",
+                        MacroCallParameterMatcher.hasNameAndStringValue("id", "CurrentContainerId")));
+    }
+
+    @Test
+    public void basicAnchorLinkCreatesMacroCall(@Mocked final ModelFormField.SubHyperlink subHyperlink) {
+
+        final Map<String, ConfigXMLReader.RequestMap> requestMapMap = new HashMap<>();
+
+        new Expectations() {
+            {
+                subHyperlink.getStyle(withNotNull());
+                result = "TestLinkStyle";
+
+                subHyperlink.getUrlMode();
+                result = "url-mode";
+
+                subHyperlink.shouldUse(withNotNull());
+                result = true;
+
+                subHyperlink.getDescription(withNotNull());
+                result = "LinkDescription";
+
+                subHyperlink.getTarget(withNotNull());
+                result = "/link/target/path";
+
+                request.getAttribute("requestMapMap");
+                result = requestMapMap;
+            }
+        };
+
+        final RenderableFtl linkElement =
+                renderableFtlFormElementsBuilder.makeHyperlinkString(subHyperlink, new HashMap<>());
+        assertThat(linkElement,
+                MacroCallMatcher.hasNameAndParameters("makeHyperlinkString",
+                        MacroCallParameterMatcher.hasNameAndStringValue("linkStyle", "TestLinkStyle"),
+                        MacroCallParameterMatcher.hasNameAndStringValue("linkUrl", "/link/target/path")));
+    }
+
+    @Test
+    public void textFieldSetsIdValueAndLength(@Mocked final ModelFormField.TextField textField) {
+        final int maxLength = 42;
+        new Expectations() {
+            {
+                modelFormField.getCurrentContainerId(withNotNull());
+                result = "CurrentTextId";
+
+                modelFormField.getEntry(withNotNull(), anyString);
+                result = "TEXTVALUE";
+
+                textField.getMaxlength();
+                result = maxLength;
+
+                httpSession.getAttribute("delegatorName");
+                result = "DelegatorName";
+            }
+        };
+
+        final HashMap<String, Object> context = new HashMap<>();
+        context.put("session", httpSession);
+
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.textField(context, textField, true);
+        assertThat(renderableFtl, MacroCallMatcher.hasNameAndParameters("renderTextField",
+                MacroCallParameterMatcher.hasNameAndStringValue("id", "CurrentTextId"),
+                MacroCallParameterMatcher.hasNameAndStringValue("value", "TEXTVALUE"),
+                MacroCallParameterMatcher.hasNameAndStringValue("maxlength", Integer.toString(maxLength))));
+
+    }
+
+    @Test
+    public void textFieldCreatesAjaxUrl(@Mocked final ModelFormField.TextField textField) {
+
+        final List<ModelForm.UpdateArea> updateAreas = ImmutableList.of(
+                new ModelForm.UpdateArea("change", "areaId1", "target1?param1=${param1}&param2=ThisIsParam2"),
+                new ModelForm.UpdateArea("change", "areaId2", "target2"));
+        new Expectations() {
+            {
+                modelFormField.getOnChangeUpdateAreas();
+                result = updateAreas;
+
+                requestHandler.makeLink(request, response, "target1");
+                result = "http://host.domain/target1";
+
+                requestHandler.makeLink(request, response, "target2");
+                result = "http://host.domain/target2";
+
+                httpSession.getAttribute("delegatorName");
+                result = "DelegatorName";
+            }
+        };
+
+        final HashMap<String, Object> context = new HashMap<>();
+        context.put("param1", "ThisIsParam1");
+        context.put("session", httpSession);
+
+        final RenderableFtl renderableFtl = renderableFtlFormElementsBuilder.textField(context, textField, true);
+        assertThat(renderableFtl, MacroCallMatcher.hasName("renderTextField"));
+        assertThat(renderableFtl, MacroCallMatcher.hasParameters(
+                MacroCallParameterMatcher.hasNameAndStringValue("ajaxUrl",
+                        "areaId1,http://host.domain/target1,param1=ThisIsParam1&param2=ThisIsParam2,"
+                                + "areaId2,http://host.domain/target2,")));
+    }
+}