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"