From 492f28eb52cdd5e7ceb9c41f13983ea54a8f8393 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 16 Oct 2026 12:59:36 +0200 Subject: [PATCH] WW-3624 feat(tags): allow regex matching of field names on fielderror tag Adds a regex attribute (default false) to s:fielderror. When enabled, fieldName and nested param values are regular expressions matched against the whole field error name, e.g. to show all errors of one iterated row. Each field error is rendered once even when several patterns match it. Implemented in the simple and html5 themes and the javatemplates FieldErrorHandler. Co-Authored-By: Claude Opus 5.5 --- .../apache/struts2/components/FieldError.java | 14 ++++ .../struts2/views/jsp/ui/FieldErrorTag.java | 7 ++ .../resources/template/html5/fielderror.ftl | 14 +++- .../resources/template/simple/fielderror.ftl | 14 +++- .../resources/tags/fielderror-attributes.html | 8 ++ .../views/jsp/ui/FieldErrorTagTest.java | 80 +++++++++++++++++++ .../views/java/simple/FieldErrorHandler.java | 15 +++- .../views/java/simple/FieldErrorTest.java | 35 ++++++++ 8 files changed, 184 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/components/FieldError.java b/core/src/main/java/org/apache/struts2/components/FieldError.java index 65a742026c..8011bd875b 100644 --- a/core/src/main/java/org/apache/struts2/components/FieldError.java +++ b/core/src/main/java/org/apache/struts2/components/FieldError.java @@ -67,6 +67,11 @@ * * <s:fielderror fieldName="field1" /> * + * <!-- example 3 --> + * <s:fielderror regex="true"> + * <s:param>ledger\.records\[<s:property value="#row.index" />\]\..*</s:param> + * </s:fielderror> + * * * * @@ -78,6 +83,7 @@ * * Example 1: display all field errors
* Example 2: display field errors only for 'field1' and 'field2'
+ * Example 3: display field errors of all fields of one iterated row, matched by a regular expression
* * * @@ -89,6 +95,7 @@ public class FieldError extends UIBean implements UnnamedParametric { private final List errorFieldNames = new ArrayList<>(); private boolean escape = true; + private boolean regex = false; public FieldError(ValueStack stack, HttpServletRequest request, HttpServletResponse response) { super(stack, request, response); @@ -108,6 +115,7 @@ protected void evaluateExtraParams() { addParameter("errorFieldNames", errorFieldNames); addParameter("escape", escape); + addParameter("regex", regex); } public void addParameter(Object value) { @@ -129,5 +137,11 @@ public void setFieldName(String fieldName) { public void setEscape(boolean escape) { this.escape = escape; } + + @StrutsTagAttribute(description="Whether field names are regular expressions matched against the whole name of a field error", + type="Boolean", defaultValue="false") + public void setRegex(boolean regex) { + this.regex = regex; + } } diff --git a/core/src/main/java/org/apache/struts2/views/jsp/ui/FieldErrorTag.java b/core/src/main/java/org/apache/struts2/views/jsp/ui/FieldErrorTag.java index 0de0ba953c..b87f323d4e 100644 --- a/core/src/main/java/org/apache/struts2/views/jsp/ui/FieldErrorTag.java +++ b/core/src/main/java/org/apache/struts2/views/jsp/ui/FieldErrorTag.java @@ -36,6 +36,7 @@ public class FieldErrorTag extends AbstractUITag { protected String fieldName; protected boolean escape = true; + protected boolean regex = false; @Override @@ -50,6 +51,7 @@ protected void populateParams() { FieldError fieldError = ((FieldError) component); fieldError.setFieldName(this.fieldName); fieldError.setEscape(escape); + fieldError.setRegex(regex); } public void setFieldName(String fieldName) { @@ -60,6 +62,10 @@ public void setEscape(boolean escape) { this.escape = escape; } + public void setRegex(boolean regex) { + this.regex = regex; + } + /** * Must declare the setter at the descendant Tag class level in order for the tag handler to locate the method. */ @@ -76,6 +82,7 @@ protected void clearTagStateForTagPoolingServers() { super.clearTagStateForTagPoolingServers(); this.fieldName = null; this.escape = true; + this.regex = false; } } diff --git a/core/src/main/resources/template/html5/fielderror.ftl b/core/src/main/resources/template/html5/fielderror.ftl index 4b43045aaf..536214a161 100644 --- a/core/src/main/resources/template/html5/fielderror.ftl +++ b/core/src/main/resources/template/html5/fielderror.ftl @@ -25,7 +25,19 @@ <#assign doneEndUlTag=false><#t/> <#assign haveMatchedErrorField=false> <#if (fieldErrorFieldNames?size > 0) > -<#list fieldErrorFieldNames as fieldErrorFieldName> +<#if attributes.regex> +<#assign matchedFieldNames = []> +<#list eKeys as eKey> +<#list fieldErrorFieldNames as fieldNamePattern> +<#if eKey?matches(fieldNamePattern)> +<#assign matchedFieldNames = matchedFieldNames + [eKey]><#break> + + + +<#else> +<#assign matchedFieldNames = fieldErrorFieldNames> + +<#list matchedFieldNames as fieldErrorFieldName> <#list eKeys as eKey> <#if (eKey = fieldErrorFieldName)> <#assign haveMatchedErrorField=true> diff --git a/core/src/main/resources/template/simple/fielderror.ftl b/core/src/main/resources/template/simple/fielderror.ftl index 1a70e1f8c3..e303bce876 100644 --- a/core/src/main/resources/template/simple/fielderror.ftl +++ b/core/src/main/resources/template/simple/fielderror.ftl @@ -25,7 +25,19 @@ <#assign doneEndUlTag=false><#t/> <#assign haveMatchedErrorField=false><#t/> <#if (fieldErrorFieldNames?size > 0) ><#t/> - <#list fieldErrorFieldNames as fieldErrorFieldName><#t/> + <#if attributes.regex><#t/> + <#assign matchedFieldNames = []><#t/> + <#list eKeys as eKey><#t/> + <#list fieldErrorFieldNames as fieldNamePattern><#t/> + <#if eKey?matches(fieldNamePattern)><#t/> + <#assign matchedFieldNames = matchedFieldNames + [eKey]><#break><#t/> + <#t/> + <#t/> + <#t/> + <#else><#t/> + <#assign matchedFieldNames = fieldErrorFieldNames><#t/> + <#t/> + <#list matchedFieldNames as fieldErrorFieldName><#t/> <#list eKeys as eKey><#t/> <#if (eKey = fieldErrorFieldName)><#t/> <#assign haveMatchedErrorField=true><#t/> diff --git a/core/src/site/resources/tags/fielderror-attributes.html b/core/src/site/resources/tags/fielderror-attributes.html index c086b6e833..a653e5c861 100644 --- a/core/src/site/resources/tags/fielderror-attributes.html +++ b/core/src/site/resources/tags/fielderror-attributes.html @@ -261,6 +261,14 @@ Boolean Whether to clear all tag state during doEndTag() processing (if applicable) + + regex + false + false + false + Boolean + Whether field names are regular expressions matched against the whole name of a field error + requiredLabel false diff --git a/core/src/test/java/org/apache/struts2/views/jsp/ui/FieldErrorTagTest.java b/core/src/test/java/org/apache/struts2/views/jsp/ui/FieldErrorTagTest.java index 4891fcfcbb..eb418c0d24 100644 --- a/core/src/test/java/org/apache/struts2/views/jsp/ui/FieldErrorTagTest.java +++ b/core/src/test/java/org/apache/struts2/views/jsp/ui/FieldErrorTagTest.java @@ -898,6 +898,86 @@ public void testWithSpecificFieldName_html5() throws Exception { strutsBodyTagsAreReflectionEqual(tag, freshTag)); } + public void testRegexMatchesAllFieldsOfOneRow() throws Exception { + String output = renderLedgerErrors(null, true, "ledger\\.records\\[1\\]\\..*"); + + assertEquals(normalize("
  • amount 1
  • date 1
", true), + normalize(output, true)); + } + + public void testRegexMatchesAllFieldsOfOneRow_html5() throws Exception { + String output = renderLedgerErrors("html5", true, "ledger\\.records\\[1\\]\\..*"); + + assertEquals(normalize("
  • amount 1
  • date 1
", true), + normalize(output, true)); + } + + public void testRegexWithoutMatchRendersNothing() throws Exception { + assertEquals("", normalize(renderLedgerErrors(null, true, "ledger\\.records\\[7\\]\\..*"), true)); + assertEquals("", normalize(renderLedgerErrors("html5", true, "ledger\\.records\\[7\\]\\..*"), true)); + } + + public void testRegexMatchesWholeFieldName() throws Exception { + assertEquals("", normalize(renderLedgerErrors(null, true, "ledger\\.records\\[1\\]"), true)); + assertEquals("", normalize(renderLedgerErrors("html5", true, "ledger\\.records\\[1\\]"), true)); + } + + public void testRegexFieldMatchedByTwoPatternsIsRenderedOnce() throws Exception { + String expected = normalize("
  • amount 0
  • amount 1
  • date 1
", true); + + assertEquals(expected, normalize(renderLedgerErrors(null, true, ".*\\.amount", "ledger\\.records\\[1\\]\\..*"), true)); + assertEquals(expected, normalize(renderLedgerErrors("html5", true, ".*\\.amount", "ledger\\.records\\[1\\]\\..*"), true)); + } + + public void testWithoutRegexPatternIsMatchedLiterally() throws Exception { + assertEquals("", normalize(renderLedgerErrors(null, false, "ledger\\.records\\[1\\]\\..*"), true)); + assertEquals("", normalize(renderLedgerErrors("html5", false, "ledger\\.records\\[1\\]\\..*"), true)); + } + + public void testRegexIsClearedForTagPoolingServers() throws Exception { + FieldErrorTag tag = new FieldErrorTag(); + tag.setPerformClearTagStateForTagPoolingServers(true); + tag.setRegex(true); + tag.setPageContext(pageContext); + tag.doStartTag(); + setComponentTagClearTagState(tag, true); + tag.doEndTag(); + + FieldErrorTag freshTag = new FieldErrorTag(); + freshTag.setPerformClearTagStateForTagPoolingServers(true); + freshTag.setPageContext(pageContext); + assertTrue("Tag state after doEndTag() and explicit tag state clearing is inequal to new Tag with pageContext/parent set. " + + "May indicate that clearTagStateForTagPoolingServers() calls are not working properly.", + strutsBodyTagsAreReflectionEqual(tag, freshTag)); + } + + private String renderLedgerErrors(String theme, boolean regex, String... fieldNames) throws Exception { + TestAction testAction = new TestAction(); + testAction.addFieldError("ledger.records[0].amount", "amount 0"); + testAction.addFieldError("ledger.records[1].amount", "amount 1"); + testAction.addFieldError("ledger.records[1].date", "date 1"); + stack.pop(); + stack.push(testAction); + writer.getBuffer().setLength(0); + + FieldErrorTag tag = new FieldErrorTag(); + if (theme != null) { + tag.setTheme(theme); + } + tag.setRegex(regex); + tag.setPageContext(pageContext); + tag.doStartTag(); + for (String fieldName : fieldNames) { + ParamTag paramTag = new ParamTag(); + paramTag.setPageContext(pageContext); + paramTag.setValue("%{'" + fieldName.replace("\\", "\\\\") + "'}"); + paramTag.doStartTag(); + paramTag.doEndTag(); + } + tag.doEndTag(); + return writer.toString(); + } + @Override public Action getAction() { return new InternalAction(); diff --git a/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/FieldErrorHandler.java b/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/FieldErrorHandler.java index aae61fa353..b32da9cead 100644 --- a/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/FieldErrorHandler.java +++ b/plugins/javatemplates/src/main/java/org/apache/struts2/views/java/simple/FieldErrorHandler.java @@ -24,6 +24,8 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; public class FieldErrorHandler extends AbstractTagHandler implements TagGenerator { public void generate() throws IOException { @@ -34,8 +36,12 @@ public void generate() throws IOException { if (fieldErrorFieldNames != null && !fieldErrorFieldNames.isEmpty() && errors != null && !errors.isEmpty()) { startUL(params); + List matchedFieldNames = Boolean.TRUE.equals(params.get("regex")) + ? matchFieldNames(fieldErrorFieldNames, errors.keySet()) + : fieldErrorFieldNames; + //iterate over field error names - for (String fieldErrorFieldName : fieldErrorFieldNames) { + for (String fieldErrorFieldName : matchedFieldNames) { List fieldErrors = errors.get(fieldErrorFieldName); if (fieldErrors != null) { for (String fieldError : fieldErrors) { @@ -58,6 +64,13 @@ public void generate() throws IOException { } } + private List matchFieldNames(List fieldNamePatterns, Set fieldNames) { + List patterns = fieldNamePatterns.stream().map(Pattern::compile).toList(); + return fieldNames.stream() + .filter(fieldName -> patterns.stream().anyMatch(pattern -> pattern.matcher(fieldName).matches())) + .toList(); + } + private void endUL() throws IOException { end("ul"); } diff --git a/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/FieldErrorTest.java b/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/FieldErrorTest.java index 686fe047a7..cd83acb68b 100644 --- a/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/FieldErrorTest.java +++ b/plugins/javatemplates/src/test/java/org/apache/struts2/views/java/simple/FieldErrorTest.java @@ -87,6 +87,41 @@ public void testRenderActionErrorNoErrors() { assertEquals("", output); } + public void testRenderFieldErrorWithRegex() { + this.errors.put("field10", Arrays.asList("too long")); + tag.setRegex(true); + tag.setFieldName("field1.*"); + tag.setFieldName("field1"); + + tag.evaluateParams(); + map.putAll(tag.getAttributes()); + theme.renderTag(getTagName(), context); + String output = writer.getBuffer().toString(); + String expected = s("
  • not good
  • bad
  • too long
"); + assertEquals(expected, output); + } + + public void testRenderFieldErrorWithRegexWithoutMatch() { + tag.setRegex(true); + tag.setFieldName("field"); + + tag.evaluateParams(); + map.putAll(tag.getAttributes()); + theme.renderTag(getTagName(), context); + String output = writer.getBuffer().toString(); + assertEquals(s("
    "), output); + } + + public void testRenderFieldErrorWithoutRegexMatchesLiterally() { + tag.setFieldName("field."); + + tag.evaluateParams(); + map.putAll(tag.getAttributes()); + theme.renderTag(getTagName(), context); + String output = writer.getBuffer().toString(); + assertEquals(s("
      "), output); + } + @Override protected void setUp() throws Exception { this.errors = new LinkedHashMap>() {