diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FeatureToggleConstants.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FeatureToggleConstants.java
index 4cc17a94b4..2db537b7c4 100644
--- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FeatureToggleConstants.java
+++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FeatureToggleConstants.java
@@ -98,4 +98,12 @@ private FeatureToggleConstants() {}
* {@code ToggleMonitorSystemPropertyFactory} or for direct -D JVM override.
*/
public static final String FT_ALLOW_MULTIPLE_FIELDS_IN_WHEN = "FT_FORMS-12053";
+ /**
+ * When enabled, the Server-Side Validation (SSV) cloud configuration dropdown is shown in the
+ * form container dialog, allowing authors to select a validator endpoint to be called before
+ * form submission. When disabled, the SSV option is hidden and no server-side validation occurs.
+ *
+ * System property: same name ({@code FT_FORMS-25252}); set to {@code "true"} to enable.
+ */
+ public static final String FT_SERVER_SIDE_VALIDATION = "FT_FORMS-25252";
}
diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java
index c82a1cc214..c8b8563b79 100644
--- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java
+++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java
@@ -213,6 +213,8 @@ private ReservedProperties() {
public static final String PN_SUBMIT_AEP_DATASET_ID = "datasetId";
public static final String PN_SUBMIT_AEP_SANDBOX_NAME = "sandboxName";
+ public static final String PN_ENABLE_SERVER_SIDE_VALIDATION = "fd:enableServerSideValidation";
+ public static final String PN_SSV_CLOUD_SERVICE_PATH = "fd:ssvCloudServicePath";
// End: Form submission related properties
private static final Set reservedProperties = aggregateReservedProperties();
diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java
index feba4ccae2..2600cb871e 100644
--- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java
+++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java
@@ -544,6 +544,10 @@ private Map getSubmitProperties() {
submitProps.computeIfAbsent(SS_AEP, k -> new LinkedHashMap());
((Map) submitProps.get(SS_AEP)).put(entry.getKey(), entry.getValue());
}
+ // SSV properties (enableServerSideValidation, ssvCloudServicePath) are intentionally
+ // excluded from the form JSON. The submit servlet reads them directly from JCR.
+ // ssvCloudServicePath in particular must not be sent to clients as it exposes
+ // internal JCR paths.
}
}
return submitProps;
diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormMetaDataDataSourceServlet.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormMetaDataDataSourceServlet.java
index b36e51e241..22ab9d1441 100644
--- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormMetaDataDataSourceServlet.java
+++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormMetaDataDataSourceServlet.java
@@ -86,7 +86,8 @@ public enum FormMetaDataType {
SUBMIT_ACTION("submitAction"),
PREFILL_ACTION("prefillServiceProvider"),
LANG("lang"),
- FORMATTERS("formatters");
+ FORMATTERS("formatters"),
+ SSV_CLOUD_CONFIG("ssvCloudServiceConfiguration");
private String value;
@@ -174,6 +175,9 @@ private Boolean isLangPolicy(FormMetaDataType type, Map.Entry en
private List getDataSourceResources(SlingHttpServletRequest request, ResourceResolver resourceResolver, FormMetaDataType type,
String dataModel, Config config) {
List resources = new ArrayList<>();
+ if (type == FormMetaDataType.SSV_CLOUD_CONFIG) {
+ return getCloudConfigsByGroup(request, resourceResolver);
+ }
FormMetaData formMetaData = resourceResolver.adaptTo(FormMetaData.class);
if (formMetaData != null) {
Iterator metaDataList = null;
@@ -223,6 +227,64 @@ private List getDataSourceResources(SlingHttpServletRequest request, R
resources.add(getResourceForDropdownDisplay(resourceResolver, i18n.get("None"), ""));
resources.addAll(this.getResourceListFromComponentDescription(metaDataList, resourceResolver));
break;
+ default:
+ break;
+ }
+ }
+ return resources;
+ }
+
+ private List getCloudConfigsByGroup(SlingHttpServletRequest request, ResourceResolver resourceResolver) {
+ List resources = new ArrayList<>();
+ resources.add(getResourceForDropdownDisplay(resourceResolver, "None", ""));
+ String contentPath = (String) request.getAttribute(Value.CONTENTPATH_ATTRIBUTE);
+ Resource formResource = null;
+ if (StringUtils.isNotBlank(contentPath)) {
+ formResource = resourceResolver.getResource(contentPath);
+ }
+ if (formResource == null) {
+ formResource = request.getRequestPathInfo().getSuffixResource();
+ }
+ String resolvedConfPath = null;
+ if (formResource != null) {
+ Resource r = formResource;
+ while (r != null) {
+ String confPath = r.getValueMap().get("cq:conf", String.class);
+ if (StringUtils.isNotBlank(confPath)) {
+ resolvedConfPath = confPath;
+ break;
+ }
+ // cq:conf lives on jcr:content for cq:Page and folder nodes
+ Resource pageContent = r.getChild(JcrConstants.JCR_CONTENT);
+ if (pageContent != null) {
+ confPath = pageContent.getValueMap().get("cq:conf", String.class);
+ if (StringUtils.isNotBlank(confPath)) {
+ resolvedConfPath = confPath;
+ break;
+ }
+ }
+ r = r.getParent();
+ }
+ }
+ // Fall back to /conf/global when no cq:conf is found in the hierarchy —
+ // this mirrors AEM's own context-aware configuration resolution behaviour.
+ if (resolvedConfPath == null) {
+ resolvedConfPath = "/conf/global";
+ }
+ // Query for any jcr:content node under cloudconfigs/ that carries serviceEndPoint,
+ // regardless of nesting depth (handles both flat and service-type-subfolder layouts).
+ String cloudConfigsBase = resolvedConfPath + "/settings/cloudconfigs";
+ String query = "SELECT * FROM [nt:base] AS s WHERE ISDESCENDANTNODE(s, '"
+ + cloudConfigsBase + "') AND s.[serviceEndPoint] IS NOT NULL";
+ Iterator results = resourceResolver.findResources(query, "JCR-SQL2");
+ while (results.hasNext()) {
+ Resource configContent = results.next();
+ // configContent is the node with serviceEndPoint — its parent is the config root node
+ Resource configNode = configContent.getParent();
+ if (configNode != null) {
+ String title = configContent.getValueMap().get("jcr:title",
+ configNode.getValueMap().get("jcr:title", configNode.getName()));
+ resources.add(getResourceForDropdownDisplay(resourceResolver, title, configNode.getPath()));
}
}
return resources;
diff --git a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java
index 15d1e1fc18..3fb34c9baf 100644
--- a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java
+++ b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java
@@ -109,6 +109,8 @@ public class FormContainerImplTest {
private static final String PATH_FORM_WITHOUT_FIELDTYPE = CONTENT_ROOT + "/formcontainerv2-without-fieldtype";
private static final String PATH_FORM_WITH_AUTO_SAVE = CONTENT_ROOT + "/formcontainerv2WithAutoSave";
private static final String PATH_FORM_WITH_CHANGE_EVENT = CONTENT_ROOT + "/formcontainerv2ChangeEventBehaviour";
+ private static final String PATH_FORM_WITH_SSV = CONTENT_ROOT + "/formcontainerv2-with-ssv";
+ private static final String PATH_FORM_SSV_DISABLED = CONTENT_ROOT + "/formcontainerv2-ssv-disabled";
private static final String PATH_FORM_1_WITHOUT_REDIRECT = CONTENT_ROOT + "/formcontainerv2WithoutRedirect";
private static final String CONTENT_FORM_WITHOUT_PREFILL_ROOT = "/content/forms/af/formWithoutPrefill";
private static final String PATH_FORM_WITHOUT_PREFILL = CONTENT_FORM_WITHOUT_PREFILL_ROOT + "/formcontainerv2WithoutPrefill";
@@ -954,4 +956,51 @@ void testSetLang() throws Exception {
formContainer.setLang(null);
assertEquals(formContainer.getLang(), "en");
}
+
+ // ─── SSV submit-properties tests ──────────────────────────────────────────
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void testSsvPropertiesNotExposedInFormJson() throws Exception {
+ // SSV config (enableServerSideValidation, ssvCloudServicePath) must never appear in
+ // the exported form JSON. The submit servlet reads them directly from JCR. Exposing
+ // ssvCloudServicePath to clients leaks an internal JCR path.
+ context.request().setAttribute(FormConstants.X_ADOBE_FORM_DEFINITION, FormConstants.FORM_DEFINITION_SUBMISSION);
+ FormContainerImpl formContainer = Utils.getComponentUnderTest(PATH_FORM_WITH_SSV, FormContainerImpl.class, context);
+
+ Map props = formContainer.getProperties();
+ assertNotNull("fd:submit must be present in submission view", props.get(ReservedProperties.FD_SUBMIT_PROPERTIES));
+
+ Map submit = (Map) props.get(ReservedProperties.FD_SUBMIT_PROPERTIES);
+ assertNull("serverSideValidation block must NOT be in the form JSON to avoid leaking JCR paths",
+ submit.get("serverSideValidation"));
+ assertFalse("enableServerSideValidation must not be a top-level submit property",
+ submit.containsKey(ReservedProperties.PN_ENABLE_SERVER_SIDE_VALIDATION));
+ assertFalse("ssvCloudServicePath must not be a top-level submit property",
+ submit.containsKey(ReservedProperties.PN_SSV_CLOUD_SERVICE_PATH));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void testSubmitPropertiesHasNoSsvBlockWhenDisabled() throws Exception {
+ context.request().setAttribute(FormConstants.X_ADOBE_FORM_DEFINITION, FormConstants.FORM_DEFINITION_SUBMISSION);
+ FormContainerImpl formContainer = Utils.getComponentUnderTest(PATH_FORM_SSV_DISABLED, FormContainerImpl.class, context);
+
+ Map props = formContainer.getProperties();
+ assertNotNull("fd:submit must be present in submission view", props.get(ReservedProperties.FD_SUBMIT_PROPERTIES));
+
+ Map submit = (Map) props.get(ReservedProperties.FD_SUBMIT_PROPERTIES);
+ assertNull("serverSideValidation block must be absent when SSV is not configured", submit.get("serverSideValidation"));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void testSubmitPropertiesAbsentWithoutSubmissionViewHeader() throws Exception {
+ // Without the submission view request attribute, fd:submit must not be exported
+ FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_WITH_SSV, FormContainer.class, context);
+
+ Map props = formContainer.getProperties();
+ assertNull("fd:submit must NOT be present in regular (non-submission-view) rendering",
+ props.get(ReservedProperties.FD_SUBMIT_PROPERTIES));
+ }
}
diff --git a/bundles/af-core/src/test/resources/form/formcontainer/test-page-content.json b/bundles/af-core/src/test/resources/form/formcontainer/test-page-content.json
index 869494a89c..23ff63a258 100644
--- a/bundles/af-core/src/test/resources/form/formcontainer/test-page-content.json
+++ b/bundles/af-core/src/test/resources/form/formcontainer/test-page-content.json
@@ -259,6 +259,22 @@
"customProp": "customPropValue"
}
},
+ "formcontainerv2-with-ssv": {
+ "jcr:primaryType": "nt:unstructured",
+ "sling:resourceType": "core/fd/components/form/container/v2/container",
+ "fieldType": "form",
+ "title": "SSV Form",
+ "fd:enableServerSideValidation": true,
+ "fd:ssvCloudServicePath": "/conf/global/settings/cloudconfigs/ssv-config",
+ "actionName": "rest"
+ },
+ "formcontainerv2-ssv-disabled": {
+ "jcr:primaryType": "nt:unstructured",
+ "sling:resourceType": "core/fd/components/form/container/v2/container",
+ "fieldType": "form",
+ "title": "No-SSV Form",
+ "actionName": "rest"
+ },
"printfragment": {
"jcr:primaryType": "nt:unstructured",
"jcr:title": "AF Fragment (v2)",
diff --git a/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json b/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
index a2710f019a..95c4e89144 100644
--- a/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
+++ b/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json
@@ -26,7 +26,8 @@
"FT_FORMS-13519",
"FT_FORMS-17107",
"FT_FORMS-24087",
- "FT_FORMS-24343"
+ "FT_FORMS-24343",
+ "FT_FORMS-25252"
],
"disabledToggles": [
]
diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml
index a30d05d26b..9ce011f785 100644
--- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml
+++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml
@@ -372,6 +372,21 @@
+
+
+
+
({
+ toObject: jest.fn(),
+ externalize: jest.fn(url => url),
+ validateURL: jest.fn(),
+ navigateTo: jest.fn(),
+ defaultErrorHandler: (...args) => mockCfDefaultErrorHandler(...args),
+ defaultSubmitSuccessHandler: (...args) => mockCfDefaultSubmitSuccessHandler(...args),
+ defaultSubmitErrorHandler: (...args) => mockCfDefaultSubmitErrorHandler(...args),
+ fetchCaptchaToken: jest.fn(),
+ dateToDaysSinceEpoch: jest.fn(),
+ downloadDoR: jest.fn(),
+ exportFormData: jest.fn()
+}));
+
+import { customFunctions } from "../src/customFunctions";
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function makeSsvGlobals(errors, formModel) {
+ return {
+ event: {
+ type: "submitError",
+ payload: {
+ body: {
+ errorType: "SSV_VALIDATION_ERROR",
+ valid: false,
+ errors: errors
+ }
+ }
+ },
+ form: {},
+ formModel: formModel
+ };
+}
+
+function makeFormModel(fields) {
+ const byQn = {};
+ fields.forEach(f => { byQn[f.qualifiedName] = f; });
+ return {
+ resolveQualifiedName: jest.fn(qn => byQn[qn] || null)
+ };
+}
+
+function makeField(name) {
+ return {
+ name: name,
+ qualifiedName: "$form." + name,
+ markAsInvalid: jest.fn()
+ };
+}
+
+// ─── tests ────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ global.alert = jest.fn();
+});
+
+describe("defaultSubmitErrorHandler", () => {
+
+ test("marks named fields invalid when SSV_VALIDATION_ERROR returned", () => {
+ const emailField = makeField("email");
+ const ageField = makeField("age");
+ const formModel = makeFormModel([emailField, ageField]);
+
+ const globals = makeSsvGlobals(
+ [
+ { qualifiedName: "$form.email", message: "Must be a valid email" },
+ { qualifiedName: "$form.age", message: "Must be 18 or older" }
+ ],
+ formModel
+ );
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(formModel.resolveQualifiedName).toHaveBeenCalledWith("$form.email");
+ expect(formModel.resolveQualifiedName).toHaveBeenCalledWith("$form.age");
+ expect(emailField.markAsInvalid).toHaveBeenCalledWith("Must be a valid email");
+ expect(ageField.markAsInvalid).toHaveBeenCalledWith("Must be 18 or older");
+ expect(mockCfDefaultSubmitErrorHandler).not.toHaveBeenCalled();
+ });
+
+ test("shows alert for form-level errors (qualifiedName absent)", () => {
+ const formModel = makeFormModel([]);
+
+ const globals = makeSsvGlobals(
+ [{ message: "Form is incomplete" }],
+ formModel
+ );
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(global.alert).toHaveBeenCalledWith("Form is incomplete");
+ expect(mockCfDefaultSubmitErrorHandler).not.toHaveBeenCalled();
+ });
+
+ test("handles mix of field-level and form-level errors", () => {
+ const emailField = makeField("email");
+ const formModel = makeFormModel([emailField]);
+
+ const globals = makeSsvGlobals(
+ [
+ { qualifiedName: "$form.email", message: "Invalid email" },
+ { message: "Please review the form" }
+ ],
+ formModel
+ );
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(emailField.markAsInvalid).toHaveBeenCalledWith("Invalid email");
+ expect(global.alert).toHaveBeenCalledWith("Please review the form");
+ expect(mockCfDefaultSubmitErrorHandler).not.toHaveBeenCalled();
+ });
+
+ test("joins multiple form-level error messages into a single alert", () => {
+ const formModel = makeFormModel([]);
+
+ const globals = makeSsvGlobals(
+ [
+ { message: "Error one" },
+ { message: "Error two" }
+ ],
+ formModel
+ );
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(global.alert).toHaveBeenCalledWith("Error one\nError two");
+ });
+
+ test("does not throw when formModel is undefined (rule-editor context)", () => {
+ // When called from a Rule Editor rule, globals.formModel is not populated.
+ // Field-level errors should be silently skipped; form-level errors still alerted.
+ const globals = makeSsvGlobals(
+ [
+ { qualifiedName: "$form.name", message: "Required" },
+ { message: "Form-level issue" }
+ ],
+ undefined // no formModel
+ );
+
+ expect(() => {
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+ }).not.toThrow();
+
+ expect(global.alert).toHaveBeenCalledWith("Form-level issue");
+ });
+
+ test("falls back to cf.defaultSubmitErrorHandler for non-SSV errors", () => {
+ const globals = {
+ event: {
+ type: "submitError",
+ payload: { body: { errorType: "SOME_OTHER_ERROR" } }
+ },
+ form: {},
+ formModel: makeFormModel([])
+ };
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(mockCfDefaultSubmitErrorHandler).toHaveBeenCalledWith("Generic error", globals);
+ expect(global.alert).not.toHaveBeenCalled();
+ });
+
+ test("falls back to cf.defaultSubmitErrorHandler when payload body is absent", () => {
+ const globals = {
+ event: { type: "submitError", payload: {} },
+ form: {},
+ formModel: makeFormModel([])
+ };
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(mockCfDefaultSubmitErrorHandler).toHaveBeenCalledWith("Generic error", globals);
+ });
+
+ test("falls back to cf.defaultSubmitErrorHandler when errors array is empty", () => {
+ const globals = makeSsvGlobals([], makeFormModel([]));
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(mockCfDefaultSubmitErrorHandler).toHaveBeenCalledWith("Generic error", globals);
+ });
+
+ test("falls back to cf.defaultSubmitErrorHandler when globals is null", () => {
+ customFunctions.defaultSubmitErrorHandler("Generic error", null);
+
+ expect(mockCfDefaultSubmitErrorHandler).toHaveBeenCalledWith("Generic error", null);
+ });
+
+ test("does not call markAsInvalid on unrelated fields", () => {
+ const emailField = makeField("email");
+ const nameField = makeField("name");
+ const formModel = makeFormModel([emailField, nameField]);
+
+ // Only email has an error; name should NOT be marked invalid
+ const globals = makeSsvGlobals(
+ [{ qualifiedName: "$form.email", message: "Bad email" }],
+ formModel
+ );
+
+ customFunctions.defaultSubmitErrorHandler("Generic error", globals);
+
+ expect(emailField.markAsInvalid).toHaveBeenCalledWith("Bad email");
+ expect(nameField.markAsInvalid).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui.frontend/__tests__/ssv-submit-error-handler.test.js b/ui.frontend/__tests__/ssv-submit-error-handler.test.js
new file mode 100644
index 0000000000..bd0b9e67dc
--- /dev/null
+++ b/ui.frontend/__tests__/ssv-submit-error-handler.test.js
@@ -0,0 +1,316 @@
+/*******************************************************************************
+ * Copyright 2025 Adobe
+ *
+ * Licensed 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.
+ ******************************************************************************/
+
+/**
+ * Tests that validateFormData from @aemforms/af-core correctly evaluates form
+ * JSON constraints, and that the SSV error pipeline maps those results to
+ * markAsInvalid calls on the right fields.
+ *
+ * This verifies the full chain:
+ * validateFormData(model, data) → ValidationError[] → {fieldName, message}
+ * → defaultSubmitErrorHandler → formModel.visit() → field.markAsInvalid()
+ */
+
+// @aemforms/af-custom-functions must be mocked before importing customFunctions
+const mockCfDefaultSubmitErrorHandler = jest.fn();
+jest.mock("@aemforms/af-custom-functions", () => ({
+ toObject: jest.fn(),
+ externalize: jest.fn(url => url),
+ validateURL: jest.fn(),
+ navigateTo: jest.fn(),
+ defaultErrorHandler: jest.fn(),
+ defaultSubmitSuccessHandler: jest.fn(),
+ defaultSubmitErrorHandler: (...args) => mockCfDefaultSubmitErrorHandler(...args),
+ fetchCaptchaToken: jest.fn(),
+ dateToDaysSinceEpoch: jest.fn(),
+ downloadDoR: jest.fn(),
+ exportFormData: jest.fn()
+}));
+
+import { validateFormData } from "@aemforms/af-core";
+import { customFunctions } from "../src/customFunctions";
+
+// ─── minimal form model factories ──────────────────────────────────────────────
+
+/**
+ * Returns the minimal form JSON required by af-core for a list of fields.
+ * Each field entry must include at least: fieldType, name, and any constraints.
+ */
+function makeFormModel(fields) {
+ return {
+ adaptiveform: "0.10.0",
+ items: fields
+ };
+}
+
+function textField(name, overrides) {
+ // id must be set explicitly; otherwise af-core generates a random one and
+ // ValidationError.fieldName (which equals this.id) won't match the field name.
+ return Object.assign({ fieldType: "text-input", id: name, name, type: "string" }, overrides);
+}
+
+function numberField(name, overrides) {
+ return Object.assign({ fieldType: "number-input", id: name, name, type: "number" }, overrides);
+}
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+/**
+ * Converts af-core ValidationError[] into the {qualifiedName, message} array that
+ * the SSV IO action sends in its HTTP 400 response body.
+ *
+ * ValidationError.fieldName is the field's id (set explicitly in our test models
+ * to equal the field name). The IO action maps these to qualifiedName by traversing
+ * the form model JSON; here we simulate that by prefixing with "$form.".
+ */
+function toSsvErrors(validationErrors) {
+ var result = [];
+ validationErrors.forEach(function(err) {
+ var qualifiedName = err.fieldName ? ("$form." + err.fieldName) : null;
+ var messages = Array.isArray(err.errorMessages) ? err.errorMessages : [];
+ messages.forEach(function(msg) {
+ result.push({ qualifiedName: qualifiedName, message: msg });
+ });
+ });
+ return result;
+}
+
+// ─── validateFormData tests ────────────────────────────────────────────────────
+
+describe("validateFormData (af-core JSON constraint validation)", () => {
+
+ test("returns valid=true and empty messages when all data satisfies constraints", () => {
+ const model = makeFormModel([
+ textField("email", { required: true }),
+ textField("name", { required: true, maxLength: 50 }),
+ numberField("age", { minimum: 0, maximum: 120 })
+ ]);
+ const data = { email: "user@example.com", name: "Alice", age: 30 };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(true);
+ expect(result.messages).toHaveLength(0);
+ });
+
+ test("reports required violation when a mandatory field is empty", () => {
+ const model = makeFormModel([
+ textField("email", { required: true })
+ ]);
+ const data = { email: "" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const emailErrors = result.messages.filter(m => m.fieldName === "email");
+ expect(emailErrors.length).toBeGreaterThan(0);
+ });
+
+ test("reports maxLength violation", () => {
+ const model = makeFormModel([
+ textField("username", { maxLength: 5 })
+ ]);
+ const data = { username: "toolongvalue" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const errors = result.messages.filter(m => m.fieldName === "username");
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ test("reports minimum violation on a number field", () => {
+ const model = makeFormModel([
+ numberField("age", { minimum: 18 })
+ ]);
+ const data = { age: 10 };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const errors = result.messages.filter(m => m.fieldName === "age");
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ test("reports maximum violation on a number field", () => {
+ const model = makeFormModel([
+ numberField("quantity", { maximum: 100 })
+ ]);
+ const data = { quantity: 200 };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const errors = result.messages.filter(m => m.fieldName === "quantity");
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ test("reports minLength violation", () => {
+ const model = makeFormModel([
+ textField("password", { minLength: 8 })
+ ]);
+ const data = { password: "abc" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const errors = result.messages.filter(m => m.fieldName === "password");
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ test("reports pattern violation", () => {
+ const model = makeFormModel([
+ textField("zip", { pattern: "^[0-9]{5}$" })
+ ]);
+ const data = { zip: "ABCDE" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const errors = result.messages.filter(m => m.fieldName === "zip");
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ test("returns valid=true when pattern constraint is satisfied", () => {
+ const model = makeFormModel([
+ textField("zip", { pattern: "^[0-9]{5}$" })
+ ]);
+ const data = { zip: "12345" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(true);
+ expect(result.messages).toHaveLength(0);
+ });
+
+ test("collects violations across multiple fields in one pass", () => {
+ const model = makeFormModel([
+ textField("email", { required: true }),
+ numberField("age", { minimum: 18 }),
+ textField("code", { pattern: "^[A-Z]{3}$" })
+ ]);
+ const data = { email: "", age: 5, code: "12" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ const fieldNames = result.messages.map(m => m.fieldName);
+ expect(fieldNames).toContain("email");
+ expect(fieldNames).toContain("age");
+ expect(fieldNames).toContain("code");
+ });
+
+ test("each ValidationError has errorMessages array with at least one string", () => {
+ const model = makeFormModel([
+ textField("name", { required: true })
+ ]);
+ const data = { name: "" };
+
+ const result = validateFormData(model, data);
+
+ expect(result.valid).toBe(false);
+ result.messages.forEach(err => {
+ expect(Array.isArray(err.errorMessages)).toBe(true);
+ expect(err.errorMessages.length).toBeGreaterThan(0);
+ expect(typeof err.errorMessages[0]).toBe("string");
+ });
+ });
+});
+
+// ─── full pipeline: validateFormData → SSV error format → defaultSubmitErrorHandler ─
+
+describe("SSV pipeline: af-core validation → markAsInvalid", () => {
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ global.alert = jest.fn();
+ });
+
+ test("errors from validateFormData trigger markAsInvalid on matching fields", () => {
+ const model = makeFormModel([
+ textField("email", { required: true }),
+ numberField("age", { minimum: 18 })
+ ]);
+ const data = { email: "", age: 10 };
+
+ const { messages } = validateFormData(model, data);
+ const ssvErrors = toSsvErrors(messages);
+
+ // Build the mock form model the way customFunctions.js uses it.
+ // qualifiedName mirrors what AF form JSON emits for a top-level field.
+ const emailField = { name: "email", qualifiedName: "$form.email", markAsInvalid: jest.fn() };
+ const ageField = { name: "age", qualifiedName: "$form.age", markAsInvalid: jest.fn() };
+ const byQn = { "$form.email": emailField, "$form.age": ageField };
+ const formModel = {
+ resolveQualifiedName: jest.fn(qn => byQn[qn] || null)
+ };
+
+ const globals = {
+ event: {
+ type: "submitError",
+ payload: {
+ body: {
+ errorType: "SSV_VALIDATION_ERROR",
+ valid: false,
+ errors: ssvErrors
+ }
+ }
+ },
+ form: {},
+ formModel
+ };
+
+ customFunctions.defaultSubmitErrorHandler("Error", globals);
+
+ expect(emailField.markAsInvalid).toHaveBeenCalled();
+ expect(ageField.markAsInvalid).toHaveBeenCalled();
+ expect(mockCfDefaultSubmitErrorHandler).not.toHaveBeenCalled();
+ });
+
+ test("valid data produces no SSV errors and handler falls through normally", () => {
+ const model = makeFormModel([
+ textField("email", { required: true }),
+ numberField("age", { minimum: 18 })
+ ]);
+ const data = { email: "test@example.com", age: 25 };
+
+ const { messages, valid } = validateFormData(model, data);
+
+ expect(valid).toBe(true);
+ expect(messages).toHaveLength(0);
+
+ // No SSV errors to pass to the handler
+ const ssvErrors = toSsvErrors(messages);
+ expect(ssvErrors).toHaveLength(0);
+ });
+
+ test("toSsvErrors flattens multiple errorMessages per field into separate entries", () => {
+ const model = makeFormModel([
+ textField("code", { minLength: 3, maxLength: 3, pattern: "^[A-Z]{3}$" })
+ ]);
+ const data = { code: "12" }; // fails minLength and pattern
+
+ const { messages } = validateFormData(model, data);
+ const ssvErrors = toSsvErrors(messages);
+
+ // Each error entry has qualifiedName and a non-empty message string
+ ssvErrors.forEach(err => {
+ expect(err.qualifiedName).toBe("$form.code");
+ expect(typeof err.message).toBe("string");
+ expect(err.message.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/ui.frontend/src/customFunctions.js b/ui.frontend/src/customFunctions.js
index d7f1ce3902..64ae259d79 100644
--- a/ui.frontend/src/customFunctions.js
+++ b/ui.frontend/src/customFunctions.js
@@ -75,12 +75,51 @@ export const customFunctions = {
/**
* Handles the error response after a form submission.
+ * When the error originates from Server-Side Validation (SSV), field-level errors
+ * are shown inline under each affected field. All other failures fall back to the
+ * default alert behaviour.
*
- * @param {string} defaultSubmitErrorMessage - The default error message.
+ * @param {string} defaultSubmitErrorMessage - Localised fallback error message.
* @param {object} globals - An object containing form instance and invoke method to call other custom functions.
* @returns {void}
*/
- defaultSubmitErrorHandler: cf.defaultSubmitErrorHandler,
+ defaultSubmitErrorHandler: function (defaultSubmitErrorMessage, globals) {
+ var payload = globals && globals.event && globals.event.payload;
+ var body = payload && payload.body;
+
+ if (body && body.errorType === 'SSV_VALIDATION_ERROR' &&
+ Array.isArray(body.errors) && body.errors.length > 0) {
+
+ var fieldErrors = body.errors.filter(function (e) { return e.qualifiedName; });
+ var formErrors = body.errors.filter(function (e) { return !e.qualifiedName; });
+
+ // Mark each named field invalid inline.
+ // globals.formModel is the actual FormModel (not the rule-node proxy) so
+ // resolveQualifiedName() and markAsInvalid() work without proxy restrictions.
+ // qualifiedName (e.g. "$form.panel.email") is the canonical AF form field identifier
+ // and is unique even when multiple panels share the same field name.
+ var formModel = globals.formModel;
+ fieldErrors.forEach(function (error) {
+ var field = formModel && typeof formModel.resolveQualifiedName === 'function'
+ ? formModel.resolveQualifiedName(error.qualifiedName)
+ : null;
+ if (field) {
+ field.markAsInvalid(error.message);
+ } else {
+ console.warn('[SSV] No field found for qualifiedName "' + error.qualifiedName + '" — error not shown inline: ' + error.message);
+ }
+ });
+
+ // Show form-level errors (no specific field) as an alert
+ if (formErrors.length > 0) {
+ window.alert(formErrors.map(function (e) { return e.message; }).join('\n'));
+ }
+
+ } else {
+ // Normal submit failure — show the generic localised message
+ cf.defaultSubmitErrorHandler(defaultSubmitErrorMessage, globals);
+ }
+ },
/**
* Fetches the captcha token for the form.
diff --git a/ui.tests/test-module/specs/formcontainer.cy.js b/ui.tests/test-module/specs/formcontainer.cy.js
index c4e1b96da5..8f1e6c1f9f 100644
--- a/ui.tests/test-module/specs/formcontainer.cy.js
+++ b/ui.tests/test-module/specs/formcontainer.cy.js
@@ -82,9 +82,9 @@ describe('Page/Form Authoring', function () {
cy.get('.cmp-adaptiveform-container__editdialog').contains('Submission').click({force:true});
cy.get(".cmp-adaptiveform-container__submitaction").children('button[is="coral-button"][aria-haspopup="listbox"]').first().click({force: true});
cy.get('coral-selectlist-item[value="fd/af/components/guidesubmittype/restendpoint"]').should('be.visible').click();
- cy.get("[name='./restEndpointPostUrl']").scrollIntoView().clear({force: true}).type("invalid-url", {force: true});
+ cy.get("[name='./restEndpointPostUrl']").scrollIntoView().clear({force: true}).type("invalid-url", {force: true}).trigger('change');
cy.get('.coral-Form-errorlabel').should('contain.text', "Please enter the absolute path of the REST endpoint.");
- cy.get("[name='./restEndpointPostUrl']").clear({force: true}).type("http://localhost:4502/some/endpoint", {force: true});
+ cy.get("[name='./restEndpointPostUrl']").clear({force: true}).type("http://localhost:4502/some/endpoint", {force: true}).trigger('change');
cy.get('.coral-Form-errorlabel').should('not.exist');
cy.get('.cq-dialog-submit').click();
};
@@ -115,7 +115,7 @@ describe('Page/Form Authoring', function () {
if (cy.af.isLatestAddon() && toggle_array.includes("FT_FORMS-9244")) {
cy.get("coral-radio[name='./restEndPointSource'][value='config']").first().click();
cy.get("[name='./restEndpointPostUrl']").scrollIntoView().should("exist").should("not.be.visible");
- cy.get("[name='./restEndpointConfigPath']").should("exist").should("be.visible");
+ cy.get("input[name='./restEndpointConfigPath']").closest('div').should("not.have.attr", "hidden");
cy.get("coral-radio[name='./restEndPointSource'][value='posturl']").first().click();
cy.get("[name='./restEndpointPostUrl']").should("exist").should("be.visible");
cy.get("[name='./restEndpointPostUrl']").should("exist").clear().type("http://localhost:4502/some/endpoint");
@@ -131,6 +131,7 @@ describe('Page/Form Authoring', function () {
cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + formContainerEditPathSelector);
cy.invokeEditableAction("[data-action='CONFIGURE']"); // this line is causing frame busting which is causing cypress to fail
cy.get('.cmp-adaptiveform-container'+'__editdialog').contains('Submission').click({force:true});
+ cy.get("[name='./actionType']").should("exist");
cy.get("[name='./actionType'] coral-select-item:selected").first().should(
"have.text",
"Submit to REST endpoint"