diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FormConstants.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FormConstants.java index 5d7c410231..1f71cd99a4 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FormConstants.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/FormConstants.java @@ -146,4 +146,12 @@ private FormConstants() { /** The resource type for date time input field v1 */ public static final String RT_FD_FORM_DATETIME_V1 = RT_FD_FORM_PREFIX + "datetime/v1/datetime"; + /** The resource type for the Adobe Sign fields datasource (Electronic Signature tab) */ + public static final String RT_FD_FORM_ADOBESIGN_FIELDS_DATASOURCE_V1 = RT_FD_FORM_PREFIX + + "container/v2/container/datasource/adobesignfields"; + + /** The resource type for the generic form-fields datasource (email/phone/countrycode autocomplete) */ + public static final String RT_FD_FORM_FIELDS_DATASOURCE_V1 = RT_FD_FORM_PREFIX + + "container/v2/container/datasource/formfields"; + } 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 ee8580198c..685d767d1b 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 @@ -15,7 +15,9 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ package com.adobe.cq.forms.core.components.internal.models.v2.form; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -33,7 +35,12 @@ import org.apache.sling.models.annotations.Default; import org.apache.sling.models.annotations.Exporter; import org.apache.sling.models.annotations.Model; -import org.apache.sling.models.annotations.injectorspecific.*; +import org.apache.sling.models.annotations.injectorspecific.ChildResource; +import org.apache.sling.models.annotations.injectorspecific.InjectionStrategy; +import org.apache.sling.models.annotations.injectorspecific.OSGiService; +import org.apache.sling.models.annotations.injectorspecific.Self; +import org.apache.sling.models.annotations.injectorspecific.SlingObject; +import org.apache.sling.models.annotations.injectorspecific.ValueMapValue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -57,6 +64,7 @@ import com.adobe.cq.forms.core.components.models.form.FormClientLibManager; import com.adobe.cq.forms.core.components.models.form.FormContainer; import com.adobe.cq.forms.core.components.models.form.FormMetaData; +import com.adobe.cq.forms.core.components.models.form.SignerInfo; import com.adobe.cq.forms.core.components.models.form.ThankYouOption; import com.adobe.cq.forms.core.components.util.AbstractContainerImpl; import com.adobe.cq.forms.core.components.util.ComponentUtils; @@ -66,6 +74,7 @@ import com.day.cq.wcm.api.PageManager; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @Model( adaptables = { SlingHttpServletRequest.class, Resource.class }, @@ -159,6 +168,20 @@ public class FormContainerImpl extends AbstractContainerImpl implements FormCont @Self(injectionStrategy = InjectionStrategy.OPTIONAL) private AutoSaveConfiguration autoSaveConfig; + /** Electronic Signature — container-level enable flag */ + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "_useSignedPdf") + private boolean useAdobeSign; + + /** Electronic Signature — signerInfo child node (contains cloud config, workflow, signers) */ + @ChildResource(injectionStrategy = InjectionStrategy.OPTIONAL, name = "signerInfo") + @Nullable + private Resource signerInfoResource; + + private String signConfigPath; + private String signingWorkflowType; + private boolean firstSignerFormFiller; + private List signers = Collections.emptyList(); + @Inject private ResourceResolver resourceResolver; @@ -488,6 +511,61 @@ public AutoSaveConfiguration getAutoSaveConfig() { return autoSaveConfig; } + @PostConstruct + private void initSigningConfig() { + if (signerInfoResource != null) { + ValueMap vm = signerInfoResource.getValueMap(); + signConfigPath = vm.get("signConfigPath", String.class); + signingWorkflowType = vm.get("workflowType", String.class); + // firstSignerFormFiller is now stored per-signer inside the multifield items + // Signer items are stored as children of signerInfo/signer (multifield) + Resource signerContainer = signerInfoResource.getChild("signer"); + if (signerContainer != null) { + List signerList = new ArrayList<>(); + for (Resource child : signerContainer.getChildren()) { + SignerInfo signer = child.adaptTo(SignerInfo.class); + if (signer != null) { + signerList.add(signer); + } + } + signers = Collections.unmodifiableList(signerList); + } + } + } + + @Override + @JsonIgnore + public boolean isUseAdobeSign() { + return useAdobeSign; + } + + @Override + @Nullable + @JsonIgnore + public String getSignConfigPath() { + return signConfigPath; + } + + @Override + @Nullable + @JsonIgnore + public String getSigningWorkflowType() { + return signingWorkflowType; + } + + @Override + @JsonIgnore + public boolean isFirstSignerFormFiller() { + return !signers.isEmpty() && signers.get(0).isFirstSignerFormFiller(); + } + + @Override + @JsonIgnore + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "signers is already Collections.unmodifiableList") + public List getSigners() { + return signers; + } + private Map getSubmitProperties() { Map submitProps = null; diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/SignerInfoImpl.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/SignerInfoImpl.java new file mode 100644 index 0000000000..403fa0afd2 --- /dev/null +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/SignerInfoImpl.java @@ -0,0 +1,182 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2024 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.internal.models.v2.form; + +import org.apache.sling.api.resource.Resource; +import org.apache.sling.models.annotations.Model; +import org.apache.sling.models.annotations.injectorspecific.InjectionStrategy; +import org.apache.sling.models.annotations.injectorspecific.ValueMapValue; +import org.jetbrains.annotations.Nullable; + +import com.adobe.cq.forms.core.components.models.form.SignerInfo; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +@Model( + adaptables = { Resource.class }, + adapters = { SignerInfo.class }) +public class SignerInfoImpl implements SignerInfo { + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "signerTitle") + @Nullable + private String signerTitle; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "emailSource") + @Nullable + private String emailSource; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "email") + @Nullable + private String email; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "emailAutocomplete") + @Nullable + private String[] emailAutocomplete; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "securityOption") + @Nullable + private String securityOption; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "countryCodeSource") + @Nullable + private String countryCodeSource; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "countryCode") + @Nullable + private String countryCode; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "countryCodeAutocomplete") + @Nullable + private String[] countryCodeAutocomplete; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "phoneSource") + @Nullable + private String phoneSource; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "phone") + @Nullable + private String phone; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "phoneAutocomplete") + @Nullable + private String[] phoneAutocomplete; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "signFieldBlocks") + @Nullable + private String[] signFieldBlocks; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "signerAfFieldsBlock") + @Nullable + private String[] signerAfFieldsBlock; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "firstSignerFormFiller") + private boolean firstSignerFormFiller = false; + + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = "signerNumber") + private int signerNumber = 1; + + @Override + @Nullable + public String getSignerTitle() { + return signerTitle; + } + + @Override + @Nullable + public String getEmailSource() { + return emailSource; + } + + @Override + @Nullable + public String getEmail() { + return email; + } + + @Override + @Nullable + @SuppressFBWarnings("EI_EXPOSE_REP") + public String[] getEmailAutocomplete() { + return emailAutocomplete != null ? emailAutocomplete.clone() : null; + } + + @Override + @Nullable + public String getSecurityOption() { + return securityOption; + } + + @Override + @Nullable + public String getCountryCodeSource() { + return countryCodeSource; + } + + @Override + @Nullable + public String getCountryCode() { + return countryCode; + } + + @Override + @Nullable + @SuppressFBWarnings("EI_EXPOSE_REP") + public String[] getCountryCodeAutocomplete() { + return countryCodeAutocomplete != null ? countryCodeAutocomplete.clone() : null; + } + + @Override + @Nullable + public String getPhoneSource() { + return phoneSource; + } + + @Override + @Nullable + public String getPhone() { + return phone; + } + + @Override + @Nullable + @SuppressFBWarnings("EI_EXPOSE_REP") + public String[] getPhoneAutocomplete() { + return phoneAutocomplete != null ? phoneAutocomplete.clone() : null; + } + + @Override + @Nullable + @SuppressFBWarnings("EI_EXPOSE_REP") + public String[] getSignFieldBlocks() { + return signFieldBlocks != null ? signFieldBlocks.clone() : null; + } + + @Override + @Nullable + @SuppressFBWarnings("EI_EXPOSE_REP") + public String[] getSignerAfFieldsBlock() { + return signerAfFieldsBlock != null ? signerAfFieldsBlock.clone() : null; + } + + @Override + public boolean isFirstSignerFormFiller() { + return firstSignerFormFiller; + } + + @Override + public int getSignerNumber() { + return signerNumber; + } +} diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/AdobeSignFieldsDataSourceServlet.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/AdobeSignFieldsDataSourceServlet.java new file mode 100644 index 0000000000..06720d0fd2 --- /dev/null +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/AdobeSignFieldsDataSourceServlet.java @@ -0,0 +1,112 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2024 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.internal.servlets; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.Servlet; + +import org.apache.jackrabbit.JcrConstants; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.SyntheticResource; +import org.apache.sling.api.resource.ValueMap; +import org.apache.sling.api.wrappers.ValueMapDecorator; +import org.jetbrains.annotations.NotNull; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import com.adobe.cq.forms.core.components.internal.form.FormConstants; +import com.adobe.cq.forms.core.components.util.ComponentUtils; +import com.adobe.granite.ui.components.ExpressionResolver; +import com.adobe.granite.ui.components.ds.DataSource; +import com.adobe.granite.ui.components.ds.SimpleDataSource; +import com.adobe.granite.ui.components.ds.ValueMapResource; + +/** + * Datasource servlet that returns Adobe Sign Block component names from the current form, + * used to populate the "signFieldBlocks" and "signerAfFieldsBlock" dropdowns + * in the Electronic Signature signer configuration dialog. + */ +@Component( + service = { Servlet.class }, + property = { + "sling.servlet.resourceTypes=" + FormConstants.RT_FD_FORM_ADOBESIGN_FIELDS_DATASOURCE_V1, + "sling.servlet.methods=GET", + "sling.servlet.extensions=html" + }) +public class AdobeSignFieldsDataSourceServlet extends AbstractDataSourceServlet { + + /** Resource type for the Adobe Sign Block v1 component — matches the constant added via the adobesignblock branch. */ + private static final String RT_ADOBE_SIGN_BLOCK = "core/fd/components/form/adobesignblock/v1/adobesignblock"; + + @Reference + private transient ExpressionResolver expressionResolver; + + @NotNull + @Override + protected ExpressionResolver getExpressionResolver() { + return expressionResolver; + } + + @Override + protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) { + ResourceResolver resourceResolver = request.getResourceResolver(); + String componentInstancePath = DatasourceComponentPathResolver.resolve(request); + List resources = new ArrayList<>(); + + if (componentInstancePath != null) { + Resource componentInstance = resourceResolver.getResource(componentInstancePath); + Resource formInstance = ComponentUtils.getFormContainer(componentInstance); + if (formInstance != null) { + collectAdobeSignBlocks(formInstance, resources, resourceResolver); + } + } + + SimpleDataSource dataSource = new SimpleDataSource(resources.iterator()); + request.setAttribute(DataSource.class.getName(), dataSource); + } + + /** + * Recursively walks the form resource tree and collects all Adobe Sign Block components. + */ + private void collectAdobeSignBlocks(Resource parent, List result, ResourceResolver resourceResolver) { + for (Resource child : parent.getChildren()) { + String resourceType = child.getValueMap().get("sling:resourceType", String.class); + if (RT_ADOBE_SIGN_BLOCK.equals(resourceType)) { + String name = child.getName(); + String title = child.getValueMap().get("name", name); + result.add(createDropdownEntry(resourceResolver, title, name)); + } + if (child.hasChildren()) { + collectAdobeSignBlocks(child, result, resourceResolver); + } + } + } + + private SyntheticResource createDropdownEntry(ResourceResolver resourceResolver, String displayValue, String dataValue) { + Map map = new HashMap<>(); + map.put("text", displayValue); + map.put("value", dataValue); + ValueMap vm = new ValueMapDecorator(map); + return new ValueMapResource(resourceResolver, "", JcrConstants.NT_UNSTRUCTURED, vm); + } +} diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/DatasourceComponentPathResolver.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/DatasourceComponentPathResolver.java new file mode 100644 index 0000000000..78abe4a9bf --- /dev/null +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/DatasourceComponentPathResolver.java @@ -0,0 +1,45 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2026 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.internal.servlets; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.SlingHttpServletRequest; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.adobe.granite.ui.components.Value; + +/** + * Resolves the path of the component being edited in a Granite UI dialog datasource request. + */ +final class DatasourceComponentPathResolver { + + private DatasourceComponentPathResolver() { + } + + @Nullable + static String resolve(@NotNull SlingHttpServletRequest request) { + String path = request.getRequestPathInfo().getSuffix(); + if (StringUtils.isNotBlank(path)) { + return path; + } + Object contentPath = request.getAttribute(Value.CONTENTPATH_ATTRIBUTE); + if (contentPath instanceof String && StringUtils.isNotBlank((String) contentPath)) { + return (String) contentPath; + } + return request.getParameter("item"); + } +} diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormFieldDataSourceServlet.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormFieldDataSourceServlet.java new file mode 100644 index 0000000000..f030f0e821 --- /dev/null +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/FormFieldDataSourceServlet.java @@ -0,0 +1,256 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2026 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.internal.servlets; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.servlet.Servlet; + +import org.apache.commons.lang3.StringUtils; +import org.apache.jackrabbit.JcrConstants; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ValueMap; +import org.apache.sling.api.wrappers.ValueMapDecorator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import com.adobe.cq.forms.core.components.internal.form.FormConstants; +import com.adobe.cq.forms.core.components.models.form.FieldType; +import com.adobe.cq.forms.core.components.util.ComponentUtils; +import com.adobe.granite.ui.components.Config; +import com.adobe.granite.ui.components.ExpressionResolver; +import com.adobe.granite.ui.components.ds.DataSource; +import com.adobe.granite.ui.components.ds.SimpleDataSource; +import com.adobe.granite.ui.components.ds.ValueMapResource; + +/** + * Datasource servlet that returns compatible input-type form field components from the current form. Used to populate + * the email, phone, and country-code field dropdowns in the Electronic Signature signer configuration dialog. + * + *

+ * Container/layout components are traversed but not included in results. Non-interactive display components (text-draw, + * title, image, buttons) are skipped. Intermediate layout nodes (for example responsive grid) are traversed. A + * {@code filter} property on the dialog datasource node restricts results by {@code fieldType}. + */ +@Component(service = { Servlet.class }, property = { + "sling.servlet.resourceTypes=" + FormConstants.RT_FD_FORM_FIELDS_DATASOURCE_V1, "sling.servlet.methods=GET", + "sling.servlet.extensions=html" }) +public class FormFieldDataSourceServlet extends AbstractDataSourceServlet { + + static final String PN_FILTER = "filter"; + + private static final Set NON_INPUT_FIELD_TYPES = Collections + .unmodifiableSet(new HashSet<>(Arrays.asList(FieldType.FORM.getValue(), FieldType.PANEL.getValue(), + FieldType.BUTTON.getValue(), FieldType.PLAIN_TEXT.getValue()))); + + /** + * Resource-type fragments that identify container/layout components. These are traversed but not added to the + * result list. + */ + private static final Set CONTAINER_RT_FRAGMENTS = new HashSet<>( + Arrays.asList("form/container/", "form/panel/", "form/panelcontainer/", "form/accordion/", "form/wizard/", + "form/tabsontop/", "form/verticaltabs/", "form/fragmentcontainer")); + + /** + * Resource-type fragments for non-data components that should be skipped entirely (no result entry, no recursion). + */ + private static final Set SKIP_RT_FRAGMENTS = new HashSet<>( + Arrays.asList("form/text/v", "form/title/", "form/image/", "form/button/", "form/actions/", + "form/adobesignblock", "form/recaptcha", "form/hcaptcha", "form/terms")); + + private static final Map> FILTER_TO_FIELD_TYPES; + + static { + Map> map = new HashMap<>(); + map.put(FieldFilter.EMAIL.getValue(), Collections.unmodifiableSet( + new HashSet<>(Arrays.asList(FieldType.TEXT_INPUT.getValue(), FieldType.EMAIL.getValue())))); + map.put(FieldFilter.PHONE.getValue(), Collections.unmodifiableSet( + new HashSet<>(Arrays.asList(FieldType.TELEPHONE.getValue(), FieldType.TEXT_INPUT.getValue())))); + map.put(FieldFilter.COUNTRY_CODE.getValue(), + Collections.unmodifiableSet(new HashSet<>(Arrays.asList(FieldType.TELEPHONE.getValue(), + FieldType.TEXT_INPUT.getValue(), FieldType.NUMBER_INPUT.getValue())))); + FILTER_TO_FIELD_TYPES = Collections.unmodifiableMap(map); + } + + enum FieldFilter { + ALL("all"), EMAIL("email"), PHONE("phone"), COUNTRY_CODE("countryCode"); + + private final String value; + + FieldFilter(String value) { + this.value = value; + } + + String getValue() { + return value; + } + + @NotNull + static FieldFilter fromString(@Nullable String value) { + if (StringUtils.isBlank(value)) { + return ALL; + } + for (FieldFilter filter : values()) { + if (StringUtils.equals(filter.value, value)) { + return filter; + } + } + return ALL; + } + } + + @Reference + private transient ExpressionResolver expressionResolver; + + @NotNull + @Override + protected ExpressionResolver getExpressionResolver() { + return expressionResolver; + } + + @Override + protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) { + ResourceResolver resourceResolver = request.getResourceResolver(); + String componentInstancePath = DatasourceComponentPathResolver.resolve(request); + List resources = new ArrayList<>(); + FieldFilter fieldFilter = resolveFieldFilter(request); + + if (componentInstancePath != null) { + Resource componentInstance = resourceResolver.getResource(componentInstancePath); + Resource formInstance = ComponentUtils.getFormContainer(componentInstance); + if (formInstance != null) { + collectFormFields(formInstance, resources, resourceResolver, fieldFilter); + } + } + + request.setAttribute(DataSource.class.getName(), new SimpleDataSource(resources.iterator())); + } + + @NotNull + FieldFilter resolveFieldFilter(@NotNull SlingHttpServletRequest request) { + Config config = getConfig(request); + if (config != null) { + return FieldFilter.fromString(getParameter(config, PN_FILTER, request, null)); + } + return FieldFilter.fromString(request.getResource().getValueMap().get(PN_FILTER, String.class)); + } + + private void collectFormFields(Resource parent, List result, ResourceResolver resolver, + FieldFilter fieldFilter) { + for (Resource child : parent.getChildren()) { + String rt = child.getValueMap().get("sling:resourceType", String.class); + if (rt != null && rt.startsWith(FormConstants.RT_FD_FORM_PREFIX)) { + if (matchesAny(rt, SKIP_RT_FRAGMENTS)) { + continue; + } + if (matchesAny(rt, CONTAINER_RT_FRAGMENTS)) { + collectFormFields(child, result, resolver, fieldFilter); + continue; + } + } + if (isInputFormField(child, rt)) { + ValueMap vm = child.getValueMap(); + String fieldName = vm.get("name", child.getName()); + if (StringUtils.isNotBlank(fieldName) && matchesFieldFilter(vm, rt, fieldFilter)) { + String fieldLabel = vm.get("fieldLabel", vm.get("jcr:title", fieldName)); + result.add(createDropdownEntry(resolver, fieldLabel + " (" + fieldName + ")", fieldName)); + } + } + if (child.hasChildren()) { + collectFormFields(child, result, resolver, fieldFilter); + } + } + } + + private boolean isInputFormField(Resource resource, @Nullable String resourceType) { + ValueMap vm = resource.getValueMap(); + if (resourceType != null && resourceType.startsWith(FormConstants.RT_FD_FORM_PREFIX)) { + if (matchesAny(resourceType, SKIP_RT_FRAGMENTS) || matchesAny(resourceType, CONTAINER_RT_FRAGMENTS)) { + return false; + } + return StringUtils.isNotBlank(vm.get("name", String.class)); + } + String fieldType = vm.get("fieldType", String.class); + if (StringUtils.isBlank(fieldType) || NON_INPUT_FIELD_TYPES.contains(fieldType)) { + return false; + } + return StringUtils.isNotBlank(vm.get("name", String.class)); + } + + boolean matchesFieldFilter(ValueMap fieldProperties, String resourceType, FieldFilter fieldFilter) { + if (fieldFilter == FieldFilter.ALL) { + return true; + } + Set allowedFieldTypes = FILTER_TO_FIELD_TYPES.get(fieldFilter.getValue()); + if (allowedFieldTypes == null) { + return true; + } + String fieldType = resolveFieldType(fieldProperties, resourceType); + return allowedFieldTypes.contains(fieldType); + } + + @NotNull + String resolveFieldType(ValueMap fieldProperties, String resourceType) { + String fieldType = fieldProperties.get("fieldType", String.class); + if (StringUtils.isNotBlank(fieldType)) { + return fieldType; + } + if (resourceType != null) { + if (resourceType.contains("emailinput")) { + return FieldType.EMAIL.getValue(); + } + if (resourceType.contains("telephoneinput")) { + return FieldType.TELEPHONE.getValue(); + } + if (resourceType.contains("numberinput")) { + return FieldType.NUMBER_INPUT.getValue(); + } + if (resourceType.contains("textinput")) { + return FieldType.TEXT_INPUT.getValue(); + } + } + return FieldType.TEXT_INPUT.getValue(); + } + + private static boolean matchesAny(String resourceType, Set fragments) { + for (String fragment : fragments) { + if (resourceType.contains(fragment)) { + return true; + } + } + return false; + } + + private Resource createDropdownEntry(ResourceResolver resolver, String displayValue, String dataValue) { + Map map = new HashMap<>(); + map.put("text", displayValue); + map.put("value", dataValue); + ValueMap vm = new ValueMapDecorator(map); + return new ValueMapResource(resolver, "", JcrConstants.NT_UNSTRUCTURED, vm); + } +} diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/ReviewDataSourceServlet.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/ReviewDataSourceServlet.java index bd73c36b6d..db95d8ed7c 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/ReviewDataSourceServlet.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/servlets/ReviewDataSourceServlet.java @@ -78,7 +78,7 @@ protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHtt ResourceResolver resourceResolver = request.getResourceResolver(); String componentInstancePath = request.getRequestPathInfo().getSuffix(); List resources = new ArrayList<>(); - if (resourceResolver != null) { + if (componentInstancePath != null) { Resource componentInstance = resourceResolver.getResource(componentInstancePath); Resource formInstance = ComponentUtils.getFormContainer(componentInstance); if (formInstance != null) { @@ -97,7 +97,7 @@ protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHtt } } } - if (name != null && title != null) { + if (org.apache.commons.lang3.StringUtils.isNotBlank(name)) { resources.add(getResourceForDropdownDisplay(resourceResolver, title, name)); } } diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/FormContainer.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/FormContainer.java index a617157c7b..0af9212eac 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/FormContainer.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/FormContainer.java @@ -16,6 +16,7 @@ package com.adobe.cq.forms.core.components.models.form; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -402,4 +403,62 @@ default AutoSaveConfiguration getAutoSaveConfig() { return null; } + /** + * Returns whether Adobe Sign Electronic Signature is enabled for this form. + * + * @return {@code true} if Adobe Sign is enabled + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @JsonIgnore + default boolean isUseAdobeSign() { + return false; + } + + /** + * Returns the path to the Adobe Sign cloud service configuration. + * + * @return cloud configuration path, or {@code null} if not configured + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + @JsonIgnore + default String getSignConfigPath() { + return null; + } + + /** + * Returns the Adobe Sign workflow type. + * Possible values: {@code SEQUENTIAL}, {@code PARALLEL}. + * + * @return workflow type, or {@code null} if not configured + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + @JsonIgnore + default String getSigningWorkflowType() { + return null; + } + + /** + * Returns whether the first signer is the form filler. + * + * @return {@code true} if the first signer fills the form + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @JsonIgnore + default boolean isFirstSignerFormFiller() { + return false; + } + + /** + * Returns the list of signer configurations for this form. + * + * @return list of {@link SignerInfo} objects, never {@code null} + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @JsonIgnore + default List getSigners() { + return Collections.emptyList(); + } + } diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/SignerInfo.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/SignerInfo.java new file mode 100644 index 0000000000..3fff740825 --- /dev/null +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/SignerInfo.java @@ -0,0 +1,181 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2024 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.models.form; + +import org.jetbrains.annotations.Nullable; +import org.osgi.annotation.versioning.ProviderType; + +/** + * Defines per-signer configuration for Adobe Sign Electronic Signature. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ +@ProviderType +public interface SignerInfo { + + /** + * Returns the signer's display title. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getSignerTitle() { + return null; + } + + /** + * Returns the source of the signer's email address. + * Possible values: {@code form}, {@code userProfile}, {@code typed}. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getEmailSource() { + return null; + } + + /** + * Returns the typed email address (used when emailSource is {@code typed}). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getEmail() { + return null; + } + + /** + * Returns the form field path for email autocomplete (used when emailSource is {@code form}). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String[] getEmailAutocomplete() { + return null; + } + + /** + * Returns the signer authentication/security option. + * Possible values: {@code NONE}, {@code PHONE}, {@code KBA}, {@code WEB_IDENTITY}. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getSecurityOption() { + return null; + } + + /** + * Returns the source of the country code (used when securityOption is {@code PHONE}). + * Possible values: {@code form}, {@code typed}. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getCountryCodeSource() { + return null; + } + + /** + * Returns the typed country code (used when countryCodeSource is {@code typed}). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getCountryCode() { + return null; + } + + /** + * Returns the form field path for country code autocomplete (used when countryCodeSource is {@code form}). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String[] getCountryCodeAutocomplete() { + return null; + } + + /** + * Returns the source of the phone number (used when securityOption is {@code PHONE}). + * Possible values: {@code form}, {@code typed}. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getPhoneSource() { + return null; + } + + /** + * Returns the typed phone number (used when phoneSource is {@code typed}). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String getPhone() { + return null; + } + + /** + * Returns the form field path for phone autocomplete (used when phoneSource is {@code form}). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String[] getPhoneAutocomplete() { + return null; + } + + /** + * Returns the Adobe Sign block field names that this signer should fill or sign. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String[] getSignFieldBlocks() { + return null; + } + + /** + * Returns the Adaptive Form field paths assigned to this signer. + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + @Nullable + default String[] getSignerAfFieldsBlock() { + return null; + } + + /** + * Returns whether this signer is the form filler (only meaningful for the first signer). + * + * @return {@code true} if the form filler is also this signer + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + default boolean isFirstSignerFormFiller() { + return false; + } + + /** + * Returns the unique signer number (1-based index within the signer list). + * + * @since com.adobe.cq.forms.core.components.models.form 5.13.0 + */ + default int getSignerNumber() { + return 1; + } +} diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/package-info.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/package-info.java index 28bc21d5c0..b0a2c26277 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/package-info.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/models/form/package-info.java @@ -35,7 +35,7 @@ *

*/ -@Version("5.12.3") +@Version("5.13.0") package com.adobe.cq.forms.core.components.models.form; import org.osgi.annotation.versioning.Version; diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/AbstractOptionsFieldImpl.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/AbstractOptionsFieldImpl.java index e1ecfd7832..21323ad2f8 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/AbstractOptionsFieldImpl.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/AbstractOptionsFieldImpl.java @@ -148,7 +148,7 @@ public String[] getOptionScreenReaderLabels() { Label label = getLabel(); String labelValue = (label != null && label.getValue() != null) ? label.getValue() : ""; - boolean hasRichTextLabel = label != null && label.isRichText() != null && label.isRichText(); + boolean hasRichTextLabel = label != null && Boolean.TRUE.equals(label.isRichText()); // Strip HTML from label once if needed String cleanLabel = hasRichTextLabel ? labelValue.replaceAll("<[^>]*>", "") : labelValue; diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/ComponentUtils.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/ComponentUtils.java index a048659ba8..b598a04ed5 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/ComponentUtils.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/util/ComponentUtils.java @@ -15,6 +15,7 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ package com.adobe.cq.forms.core.components.util; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -338,7 +339,7 @@ public static List getSupportedSubmitActions(HttpClientBuilderFactory cl } } } - } catch (Exception e) { + } catch (IOException e) { logger.error("Error while fetching supported submit actions", e); } CacheManager.putInCache(CacheManager.SUPPORTED_SUBMIT_ACTIONS_CACHE_KEY, supportedSubmitActions); 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 43b2d53428..7b56607857 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 @@ -102,6 +102,7 @@ public class FormContainerImplTest { private static final String PATH_FORM_WITH_AUTO_SAVE = CONTENT_ROOT + "/formcontainerv2WithAutoSave"; 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_WITH_ADOBE_SIGN = CONTENT_FORM_WITHOUT_PREFILL_ROOT + "/formcontainerv2WithAdobeSign"; private static final String PATH_FORM_WITHOUT_PREFILL = CONTENT_FORM_WITHOUT_PREFILL_ROOT + "/formcontainerv2WithoutPrefill"; private static final String PATH_FORM_WITH_SPEC = CONTENT_FORM_WITHOUT_PREFILL_ROOT + "/formcontainerv2withspecversion"; private static final String LIB_FORM_CONTAINER = "/libs/core/fd/components/form/container/v2/container"; @@ -917,4 +918,49 @@ void testSetLang() throws Exception { formContainer.setLang(null); assertEquals(formContainer.getLang(), "en"); } + + @Test + void testAdobeSignDisabledByDefault() throws Exception { + FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_WITHOUT_PREFILL, FormContainer.class, context); + assertFalse(formContainer.isUseAdobeSign()); + assertNull(formContainer.getSignConfigPath()); + assertNull(formContainer.getSigningWorkflowType()); + assertFalse(formContainer.isFirstSignerFormFiller()); + assertNotNull(formContainer.getSigners()); + assertTrue(formContainer.getSigners().isEmpty()); + } + + @Test + void testAdobeSignEnabled() throws Exception { + FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_WITH_ADOBE_SIGN, FormContainer.class, context); + assertTrue(formContainer.isUseAdobeSign()); + assertEquals("/etc/cloudservices/echosign/testconfig", formContainer.getSignConfigPath()); + assertEquals("SEQUENTIAL", formContainer.getSigningWorkflowType()); + assertTrue(formContainer.isFirstSignerFormFiller()); + } + + @Test + void testAdobeSignSigners() throws Exception { + FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_WITH_ADOBE_SIGN, FormContainer.class, context); + List signers = formContainer.getSigners(); + assertNotNull(signers); + assertEquals(1, signers.size()); + + SignerInfo signer = signers.get(0); + assertEquals("Test Signer", signer.getSignerTitle()); + assertEquals("typed", signer.getEmailSource()); + assertEquals("signer@example.com", signer.getEmail()); + assertEquals("PHONE", signer.getSecurityOption()); + assertEquals("typed", signer.getCountryCodeSource()); + assertEquals("+1", signer.getCountryCode()); + assertEquals("typed", signer.getPhoneSource()); + assertEquals("1234567890", signer.getPhone()); + assertNotNull(signer.getSignFieldBlocks()); + assertEquals(1, signer.getSignFieldBlocks().length); + assertEquals("adobesignblock1", signer.getSignFieldBlocks()[0]); + assertNotNull(signer.getSignerAfFieldsBlock()); + assertEquals(1, signer.getSignerAfFieldsBlock().length); + assertEquals("textField1", signer.getSignerAfFieldsBlock()[0]); + assertEquals(1, signer.getSignerNumber()); + } } diff --git a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/SignerInfoImplTest.java b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/SignerInfoImplTest.java new file mode 100644 index 0000000000..e46aa21686 --- /dev/null +++ b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/SignerInfoImplTest.java @@ -0,0 +1,84 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2024 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.internal.models.v2.form; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.adobe.cq.forms.core.components.models.form.SignerInfo; +import com.adobe.cq.forms.core.context.FormsCoreComponentTestContext; +import io.wcm.testing.mock.aem.junit5.AemContext; +import io.wcm.testing.mock.aem.junit5.AemContextExtension; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +@ExtendWith(AemContextExtension.class) +public class SignerInfoImplTest { + + private static final String BASE = "/form/formcontainer"; + private static final String TEST_CONTENT = BASE + "/test-content-signer.json"; + private static final String SIGNER_ROOT = "/content/forms/af/test/signer"; + + private final AemContext context = FormsCoreComponentTestContext.newAemContext(); + + @BeforeEach + void setUp() { + context.load().json(TEST_CONTENT, SIGNER_ROOT); + } + + @Test + void testDefaultSignerInfo() { + SignerInfo defaultSigner = new SignerInfo() {}; + assertNull(defaultSigner.getSignerTitle()); + assertNull(defaultSigner.getEmailSource()); + assertNull(defaultSigner.getEmail()); + assertNull(defaultSigner.getEmailAutocomplete()); + assertNull(defaultSigner.getSecurityOption()); + assertNull(defaultSigner.getCountryCodeSource()); + assertNull(defaultSigner.getCountryCode()); + assertNull(defaultSigner.getCountryCodeAutocomplete()); + assertNull(defaultSigner.getPhoneSource()); + assertNull(defaultSigner.getPhone()); + assertNull(defaultSigner.getPhoneAutocomplete()); + assertNull(defaultSigner.getSignFieldBlocks()); + assertNull(defaultSigner.getSignerAfFieldsBlock()); + assertEquals(1, defaultSigner.getSignerNumber()); + } + + @Test + void testSignerInfoFromResource() { + SignerInfo signer = context.currentResource(SIGNER_ROOT).adaptTo(SignerInfo.class); + assertNotNull(signer); + assertEquals("Lead Signer", signer.getSignerTitle()); + assertEquals("typed", signer.getEmailSource()); + assertEquals("lead@example.com", signer.getEmail()); + assertNull(signer.getEmailAutocomplete()); + assertEquals("PHONE", signer.getSecurityOption()); + assertEquals("typed", signer.getCountryCodeSource()); + assertEquals("+1", signer.getCountryCode()); + assertNull(signer.getCountryCodeAutocomplete()); + assertEquals("typed", signer.getPhoneSource()); + assertEquals("9876543210", signer.getPhone()); + assertNull(signer.getPhoneAutocomplete()); + assertArrayEquals(new String[] { "block1", "block2" }, signer.getSignFieldBlocks()); + assertArrayEquals(new String[] { "emailField" }, signer.getSignerAfFieldsBlock()); + assertEquals(1, signer.getSignerNumber()); + } +} diff --git a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/servlets/FormFieldDataSourceServletTest.java b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/servlets/FormFieldDataSourceServletTest.java new file mode 100644 index 0000000000..d0392cb6a7 --- /dev/null +++ b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/servlets/FormFieldDataSourceServletTest.java @@ -0,0 +1,150 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Copyright 2026 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. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ +package com.adobe.cq.forms.core.components.internal.servlets; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.sling.testing.mock.sling.servlet.MockRequestPathInfo; +import org.apache.sling.testing.mock.sling.servlet.MockSlingHttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.adobe.cq.forms.core.Utils; +import com.adobe.cq.forms.core.context.FormsCoreComponentTestContext; +import com.adobe.granite.ui.components.ExpressionResolver; +import com.adobe.granite.ui.components.Value; +import com.adobe.granite.ui.components.ds.DataSource; +import io.wcm.testing.mock.aem.junit5.AemContext; +import io.wcm.testing.mock.aem.junit5.AemContextExtension; + +import static com.adobe.cq.forms.core.components.internal.servlets.AbstractDataSourceServlet.PN_VALUE; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith({ AemContextExtension.class, MockitoExtension.class }) +class FormFieldDataSourceServletTest { + + private static final String TEST_BASE = "/form/formfields/datasource"; + private static final String APPS_ROOT = "/apps"; + private static final String FORM_CONTAINER_PATH = "/apps/formcontainerWithFields"; + + public final AemContext context = FormsCoreComponentTestContext.newAemContext(); + + @Mock + private ExpressionResolver expressionResolver; + + private FormFieldDataSourceServlet servlet; + + @BeforeEach + void setUp() { + context.load().json(TEST_BASE + FormsCoreComponentTestContext.TEST_CONTENT_JSON, APPS_ROOT); + servlet = new FormFieldDataSourceServlet(); + Utils.setInternalState(servlet, "expressionResolver", expressionResolver); + } + + @Test + void testEmailFilter() { + List values = getDataSourceValues("/apps/emailFieldDatasource", FORM_CONTAINER_PATH); + assertEquals(Arrays.asList("emailText", "signerEmail"), values); + } + + @Test + void testPhoneFilter() { + List values = getDataSourceValues("/apps/phoneFieldDatasource", FORM_CONTAINER_PATH); + assertEquals(Arrays.asList("emailText", "signerPhone"), values); + } + + @Test + void testCountryCodeFilter() { + List values = getDataSourceValues("/apps/countryCodeFieldDatasource", FORM_CONTAINER_PATH); + assertEquals(Arrays.asList("countryCode", "emailText", "signerPhone"), values); + } + + @Test + void testFormContainerNull() { + List values = getDataSourceValues("/apps/emailFieldDatasource", "/apps/missing"); + assertTrue(values.isEmpty()); + } + + @Test + void testEmailFilterWithResponsiveGrid() { + List values = getDataSourceValues("/apps/emailFieldDatasource", "/apps/formcontainerWithResponsiveGrid"); + assertEquals(Arrays.asList("gridEmail"), values); + } + + @Test + void testResolveComponentPathFromContentPathAttribute() { + when(expressionResolver.resolve(any(), any(), any(), any(MockSlingHttpServletRequest.class))) + .then(returnsFirstArg()); + context.currentResource("/apps/emailFieldDatasource"); + MockSlingHttpServletRequest request = context.request(); + request.setAttribute(Value.CONTENTPATH_ATTRIBUTE, FORM_CONTAINER_PATH); + MockRequestPathInfo requestPathInfo = (MockRequestPathInfo) request.getRequestPathInfo(); + requestPathInfo.setSuffix(null); + servlet.doGet(request, context.response()); + DataSource dataSource = (DataSource) request.getAttribute(DataSource.class.getName()); + assertNotNull(dataSource); + assertTrue(dataSource.iterator().hasNext()); + } + + @Test + void testResolveFieldFilterFromResource() { + context.currentResource("/apps/emailFieldDatasource/datasource"); + assertEquals(FormFieldDataSourceServlet.FieldFilter.EMAIL, servlet.resolveFieldFilter(context.request())); + } + + @Test + void testResolveFieldTypeFromResourceType() { + assertEquals("email", servlet.resolveFieldType( + new org.apache.sling.api.wrappers.ValueMapDecorator(java.util.Collections.emptyMap()), + "core/fd/components/form/emailinput/v1/emailinput")); + assertEquals("tel", servlet.resolveFieldType( + new org.apache.sling.api.wrappers.ValueMapDecorator(java.util.Collections.emptyMap()), + "core/fd/components/form/telephoneinput/v1/telephoneinput")); + } + + @Test + void testMatchesFieldFilterExcludesCheckbox() { + org.apache.sling.api.wrappers.ValueMapDecorator vm = new org.apache.sling.api.wrappers.ValueMapDecorator( + Collections.singletonMap("fieldType", "checkbox")); + assertFalse(servlet.matchesFieldFilter(vm, "core/fd/components/form/checkbox/v1/checkbox", + FormFieldDataSourceServlet.FieldFilter.EMAIL)); + } + + private List getDataSourceValues(String datasourceResourcePath, String formContainerSuffix) { + when(expressionResolver.resolve(any(), any(), any(), any(MockSlingHttpServletRequest.class))) + .then(returnsFirstArg()); + context.currentResource(datasourceResourcePath); + MockSlingHttpServletRequest request = context.request(); + MockRequestPathInfo requestPathInfo = (MockRequestPathInfo) request.getRequestPathInfo(); + requestPathInfo.setSuffix(formContainerSuffix); + servlet.doGet(request, context.response()); + DataSource dataSource = (DataSource) request.getAttribute(DataSource.class.getName()); + assertNotNull(dataSource); + List values = new ArrayList<>(); + dataSource.iterator().forEachRemaining(resource -> values.add(resource.getValueMap().get(PN_VALUE, String.class))); + return values.stream().sorted().collect(Collectors.toList()); + } +} diff --git a/bundles/af-core/src/test/resources/form/formcontainer/test-content-signer.json b/bundles/af-core/src/test/resources/form/formcontainer/test-content-signer.json new file mode 100644 index 0000000000..58ee884611 --- /dev/null +++ b/bundles/af-core/src/test/resources/form/formcontainer/test-content-signer.json @@ -0,0 +1,14 @@ +{ + "jcr:primaryType": "nt:unstructured", + "signerTitle": "Lead Signer", + "emailSource": "typed", + "email": "lead@example.com", + "securityOption": "PHONE", + "countryCodeSource": "typed", + "countryCode": "+1", + "phoneSource": "typed", + "phone": "9876543210", + "signFieldBlocks": ["block1", "block2"], + "signerAfFieldsBlock": ["emailField"], + "signerNumber": 1 +} diff --git a/bundles/af-core/src/test/resources/form/formcontainer/test-content.json b/bundles/af-core/src/test/resources/form/formcontainer/test-content.json index fe619539fb..2f594d4950 100644 --- a/bundles/af-core/src/test/resources/form/formcontainer/test-content.json +++ b/bundles/af-core/src/test/resources/form/formcontainer/test-content.json @@ -76,5 +76,36 @@ "thankyouPage": "/a/b/c", "thankyouMessage": "message", "id": "L2NvbnRlbnQvZm9ybXMvYWYvYWYy" + }, + "formcontainerv2WithAdobeSign": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/container/v2/container", + "thankyouPage": "/a/b/c", + "thankyouMessage": "message", + "fieldType": "form", + "_useSignedPdf": true, + "signerInfo": { + "jcr:primaryType": "nt:unstructured", + "signConfigPath": "/etc/cloudservices/echosign/testconfig", + "workflowType": "SEQUENTIAL", + "signer": { + "jcr:primaryType": "nt:unstructured", + "item0": { + "jcr:primaryType": "nt:unstructured", + "firstSignerFormFiller": true, + "signerTitle": "Test Signer", + "emailSource": "typed", + "email": "signer@example.com", + "securityOption": "PHONE", + "countryCodeSource": "typed", + "countryCode": "+1", + "phoneSource": "typed", + "phone": "1234567890", + "signFieldBlocks": ["adobesignblock1"], + "signerAfFieldsBlock": ["textField1"], + "signerNumber": 1 + } + } + } } } \ No newline at end of file diff --git a/bundles/af-core/src/test/resources/form/formfields/datasource/test-content.json b/bundles/af-core/src/test/resources/form/formfields/datasource/test-content.json new file mode 100644 index 0000000000..be4b551d44 --- /dev/null +++ b/bundles/af-core/src/test/resources/form/formfields/datasource/test-content.json @@ -0,0 +1,89 @@ +{ + "emailFieldDatasource": { + "jcr:primaryType": "nt:unstructured", + "datasource": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/container/v2/container/datasource/formfields", + "filter": "email" + } + }, + "phoneFieldDatasource": { + "jcr:primaryType": "nt:unstructured", + "datasource": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/container/v2/container/datasource/formfields", + "filter": "phone" + } + }, + "countryCodeFieldDatasource": { + "jcr:primaryType": "nt:unstructured", + "datasource": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/container/v2/container/datasource/formfields", + "filter": "countryCode" + } + }, + "formcontainerWithResponsiveGrid": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/container/v2/container", + "fieldType": "form", + "responsivegrid": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "wcm/foundation/components/responsivegrid", + "emailinput": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/emailinput/v1/emailinput", + "name": "gridEmail", + "jcr:title": "Grid Email", + "fieldType": "email" + } + } + }, + "formcontainerWithFields": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/container/v2/container", + "fieldType": "form", + "textinput": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/textinput/v1/textinput", + "name": "emailText", + "jcr:title": "Email Text", + "fieldType": "text-input" + }, + "emailinput": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/emailinput/v1/emailinput", + "name": "signerEmail", + "jcr:title": "Signer Email", + "fieldType": "email" + }, + "telephoneinput": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/telephoneinput/v1/telephoneinput", + "name": "signerPhone", + "jcr:title": "Signer Phone", + "fieldType": "tel" + }, + "numberinput": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/numberinput/v1/numberinput", + "name": "countryCode", + "jcr:title": "Country Code", + "fieldType": "number-input" + }, + "checkbox": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/checkbox/v1/checkbox", + "name": "agree", + "jcr:title": "Agree", + "fieldType": "checkbox" + }, + "datepicker": { + "jcr:primaryType": "nt:unstructured", + "sling:resourceType": "core/fd/components/form/datepicker/v1/datepicker", + "name": "dob", + "jcr:title": "Date of Birth", + "fieldType": "date-input" + } + } +} 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 0f41726075..4c9f712b83 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 @@ -18,7 +18,7 @@ jcr:primaryType="nt:unstructured" jcr:title="Adaptive Form Container" sling:resourceType="cq/gui/components/authoring/dialog" - extraClientlibs="[core.forms.components.container.v1.editor]" + extraClientlibs="[core.forms.components.container.v1.editor,core.forms.components.container.v2.editor]" helpPath="https://www.adobe.com/go/aem_af_cmp_container_v2" trackingFeature="core-components:adaptiveform-container:v2"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/css.txt b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/css.txt new file mode 100644 index 0000000000..25bc2166ce --- /dev/null +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/css.txt @@ -0,0 +1,18 @@ +############################################################################### +# Copyright 2026 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. +############################################################################### + +#base=css +electronicSignatureDialog.css diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/css/electronicSignatureDialog.css b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/css/electronicSignatureDialog.css new file mode 100644 index 0000000000..7fdb858860 --- /dev/null +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/css/electronicSignatureDialog.css @@ -0,0 +1,35 @@ +/* + * Copyright 2026 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. + */ + +.cmp-adaptiveform-container__signer-phonecontainer { + margin-top: 0.5rem; +} + +.cmp-adaptiveform-container__signer-phonecontainer .cmp-adaptiveform-container__signer-phone-country, +.cmp-adaptiveform-container__signer-phonecontainer .cmp-adaptiveform-container__signer-phone-number { + margin-bottom: 0.75rem; +} + +.cmp-adaptiveform-container__signer-phonecontainer .cmp-adaptiveform-container__signer-phone-number { + margin-bottom: 0; +} + +/* First signer: no delete, no drag handle */ +.cmp-adaptiveform-container__signer-multifield coral-multifield-item:first-child button[coral-multifield-remove], +.cmp-adaptiveform-container__signer-multifield coral-multifield-item:first-child button[coral-multifield-move], +.cmp-adaptiveform-container__signer-multifield coral-multifield-item:first-child coral-handle { + display: none !important; +} diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js.txt b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js.txt index dd642eaed7..2698aa1b7d 100644 --- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js.txt +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js.txt @@ -15,4 +15,5 @@ ############################################################################### #base=js -editDialog.js \ No newline at end of file +editDialog.js +electronicSignatureDialog.js \ No newline at end of file diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js/electronicSignatureDialog.js b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js/electronicSignatureDialog.js new file mode 100644 index 0000000000..8921e32a0a --- /dev/null +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/clientlibs/editor/js/electronicSignatureDialog.js @@ -0,0 +1,409 @@ +/******************************************************************************* + * Copyright 2026 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. + ******************************************************************************/ +(function($, channel, Coral) { + "use strict"; + + var SELECTORS = { + esignTab: ".cmp-adaptiveform-container__esign-tab", + enableCheckbox: ".cmp-adaptiveform-container__esign-enable", + signingConfig: ".cmp-adaptiveform-container__esign-config", + signerMultifield: "coral-multifield[name='./signerInfo/signer']", + signerMultifieldClass: ".cmp-adaptiveform-container__signer-multifield", + signerItem: ".cmp-adaptiveform-container__signer-item", + firstSignerRadiogroup: ".cmp-adaptiveform-container__signer-firstsigner", + signConfigPath: "coral-select[name='./signerInfo/signConfigPath']", + emailSource: ".cmp-adaptiveform-container__signer-emailsource", + emailAutocomplete: ".cmp-adaptiveform-container__signer-emailautocomplete", + email: ".cmp-adaptiveform-container__signer-email", + securityOption: ".cmp-adaptiveform-container__signer-securityoption", + phoneContainer: ".cmp-adaptiveform-container__signer-phonecontainer", + countryCodeSource: ".cmp-adaptiveform-container__signer-countrycodesource", + countryCodeAutocomplete: ".cmp-adaptiveform-container__signer-countrycodeautocomplete", + countryCode: ".cmp-adaptiveform-container__signer-countrycode", + phoneSource: ".cmp-adaptiveform-container__signer-phonesource", + phoneAutocomplete: ".cmp-adaptiveform-container__signer-phoneautocomplete", + phone: ".cmp-adaptiveform-container__signer-phone" + }; + + function isChecked(el) { + var checkbox = el instanceof $ ? el[0] : el; + if (checkbox && checkbox.tagName && checkbox.tagName.toLowerCase() === "coral-checkbox") { + return checkbox.checked; + } + return $(checkbox).is(":checked"); + } + + /** + * Returns the selected value from a coral-select, whether $el is the + * coral-select itself or a containing wrapper (granite:class on wrapper). + */ + function getSelectValue($el) { + var coralSelect = $el.is("coral-select") ? $el[0] : $el.find("coral-select")[0]; + return coralSelect ? (coralSelect.value || "") : ""; + } + + /** + * Show or hide a field wrapper or plain container. + * Caps the closest(".coral-Form-fieldwrapper") search at the signer-item + * boundary so the multifield itself is never accidentally hidden. + */ + function setVisible($el, visible) { + var $signerItem = $el.closest(SELECTORS.signerItem); + var $candidate = $el.closest(".coral-Form-fieldwrapper"); + var useWrapper = $candidate.length && ( + !$signerItem.length || $.contains($signerItem[0], $candidate[0]) + ); + (useWrapper ? $candidate : $el).toggle(visible); + } + + function toggleSigningConfig(dialog) { + var $dialog = $(dialog); + var enabled = isChecked($dialog.find(SELECTORS.enableCheckbox)); + $dialog.find(SELECTORS.signingConfig).toggle(enabled); + if (enabled) { + ensureDefaultSigner(dialog); + } + } + + /** + * Adds one signer item if the multifield is empty. + * Called whenever Adobe Sign is enabled (dialog open or checkbox checked). + */ + var firstSignerMultifieldItem = null; + + function getSignerMultifield(dialog) { + return $(dialog).find(SELECTORS.signerMultifield)[0]; + } + + function ensureDefaultSigner(dialog) { + var mf = getSignerMultifield(dialog); + if (!mf) { + return; + } + Coral.commons.ready(mf, function() { + if (mf.items.length === 0) { + var addBtn = mf.querySelector("[coral-multifield-add]"); + if (addBtn) { + addBtn.click(); + } + } + captureFirstSignerMultifieldItem(dialog); + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + }); + } + + function captureFirstSignerMultifieldItem(dialog) { + var mf = getSignerMultifield(dialog); + if (!mf || mf.items.length === 0) { + return; + } + if (!firstSignerMultifieldItem) { + firstSignerMultifieldItem = mf.items.getAll()[0]; + } + } + + function setMultifieldControlVisible(item, selector, visible) { + var control = item.querySelector(selector); + if (control) { + control.hidden = !visible; + control.style.display = visible ? "" : "none"; + } + } + + /** + * First signer: hide delete and drag controls. Additional signers: show both. + */ + function updateMultifieldItemChrome(dialog) { + var mf = getSignerMultifield(dialog); + if (!mf) { + return; + } + captureFirstSignerMultifieldItem(dialog); + mf.items.getAll().forEach(function(item, index) { + var isFirst = index === 0; + setMultifieldControlVisible(item, "button[coral-multifield-remove]", !isFirst); + setMultifieldControlVisible(item, "button[coral-multifield-move]", !isFirst); + setMultifieldControlVisible(item, "coral-handle", !isFirst); + }); + enforceFirstSignerPosition(dialog); + } + + function enforceFirstSignerPosition(dialog) { + var mf = getSignerMultifield(dialog); + if (!mf || !firstSignerMultifieldItem || mf.items.length < 2) { + return; + } + var items = mf.items.getAll(); + if (items[0] !== firstSignerMultifieldItem) { + mf.items.remove(firstSignerMultifieldItem); + mf.items.add(firstSignerMultifieldItem, 0); + } + } + + function setFirstSignerRadiogroupValue(rg, value) { + if (!rg) { + return; + } + rg.value = value; + if (rg.items && rg.items.getAll) { + rg.items.getAll().forEach(function(item) { + item.checked = item.value === value; + }); + } + } + + /** + * First signer: "Is form filler first signer?" is editable. + * Other signers: visible, locked to "No", disabled. + */ + function updateFirstSignerState(dialog) { + $(dialog).find(SELECTORS.signerMultifield) + .find("coral-multifield-item") + .each(function(index) { + var $item = $(this).find(SELECTORS.signerItem); + var rg = $item.find(SELECTORS.firstSignerRadiogroup)[0]; + if (!rg) { + return; + } + if (index === 0) { + rg.disabled = false; + rg.readOnly = false; + } else { + setFirstSignerRadiogroupValue(rg, "false"); + rg.disabled = true; + rg.readOnly = true; + } + }); + } + + var DEFAULT_EMAIL_SOURCE = "form"; + var DEFAULT_SECURITY_OPTION = "NONE"; + var DEFAULT_FIELD_SOURCE = "form"; + + function normalizeEmailSource(val) { + return val || DEFAULT_EMAIL_SOURCE; + } + + function normalizeSecurityOption(val) { + return val || DEFAULT_SECURITY_OPTION; + } + + function hidePhoneFields($item) { + setVisible($item.find(SELECTORS.phoneContainer), false); + } + + function applyEmailSourceRules($item) { + var val = normalizeEmailSource(getSelectValue($item.find(SELECTORS.emailSource))); + setVisible($item.find(SELECTORS.emailAutocomplete), val === "form"); + setVisible($item.find(SELECTORS.email), val === "typed"); + } + + function applySecurityOptionRules($item) { + var val = normalizeSecurityOption(getSelectValue($item.find(SELECTORS.securityOption))); + if (val !== "PHONE") { + hidePhoneFields($item); + return; + } + setVisible($item.find(SELECTORS.phoneContainer), true); + applyCountryCodeSourceRules($item); + applyPhoneSourceRules($item); + } + + function applyCountryCodeSourceRules($item) { + var val = getSelectValue($item.find(SELECTORS.countryCodeSource)) || DEFAULT_FIELD_SOURCE; + setVisible($item.find(SELECTORS.countryCodeSource), true); + setVisible($item.find(SELECTORS.countryCodeAutocomplete), val === "form"); + setVisible($item.find(SELECTORS.countryCode), val === "typed"); + } + + function applyPhoneSourceRules($item) { + var val = getSelectValue($item.find(SELECTORS.phoneSource)) || DEFAULT_FIELD_SOURCE; + setVisible($item.find(SELECTORS.phoneSource), true); + setVisible($item.find(SELECTORS.phoneAutocomplete), val === "form"); + setVisible($item.find(SELECTORS.phone), val === "typed"); + } + + function applySignerItemRules($item) { + applyEmailSourceRules($item); + applySecurityOptionRules($item); + } + + function initSignerItem(signerItemEl) { + var $item = $(signerItemEl); + if (!$item.length) { + return; + } + Coral.commons.ready($item[0], function() { + applySignerItemRules($item); + }); + } + + function initAllSignerItems(dialog) { + $(dialog).find(SELECTORS.signerItem).each(function() { + initSignerItem(this); + }); + } + + function wireSignerItemListeners(dialog) { + var $dialog = $(dialog); + + $dialog.on("change", SELECTORS.emailSource, function(e) { + applyEmailSourceRules($(e.target).closest(SELECTORS.signerItem)); + }); + $dialog.on("change", SELECTORS.securityOption, function(e) { + applySecurityOptionRules($(e.target).closest(SELECTORS.signerItem)); + }); + $dialog.on("change", SELECTORS.countryCodeSource, function(e) { + applyCountryCodeSourceRules($(e.target).closest(SELECTORS.signerItem)); + }); + $dialog.on("change", SELECTORS.phoneSource, function(e) { + applyPhoneSourceRules($(e.target).closest(SELECTORS.signerItem)); + }); + } + + function wireMultifieldListeners(dialog) { + var $dialog = $(dialog); + var mf = getSignerMultifield(dialog); + + $dialog.on("coral-collection:add", SELECTORS.signerMultifield, function(e) { + var newItem = e.detail.item; + Coral.commons.ready(newItem, function() { + var $newItem = $(newItem).find(SELECTORS.signerItem); + if ($newItem.length) { + initSignerItem($newItem[0]); + } + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + }); + }); + + $dialog.on("coral-collection:remove", SELECTORS.signerMultifield, function() { + captureFirstSignerMultifieldItem(dialog); + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + }); + + if (mf && !mf.dataset.esignReorderGuard) { + mf.dataset.esignReorderGuard = "true"; + mf.addEventListener("coral-collection:reorder", function() { + enforceFirstSignerPosition(dialog); + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + }); + } + } + + /** + * Validates required signing config fields when Adobe Sign is enabled. + * Returns true if valid. + */ + function validateSigningConfig(dialog) { + if (!isChecked($(dialog).find(SELECTORS.enableCheckbox))) { + return true; + } + var $configSelect = $(dialog).find(SELECTORS.signConfigPath); + if (!$configSelect.length || !getSelectValue($configSelect)) { + markFieldInvalid($configSelect, "Adobe Sign cloud configuration is required."); + return false; + } + clearFieldInvalid($configSelect); + return true; + } + + function markFieldInvalid($coralSelect, message) { + var el = $coralSelect[0]; + if (!el) return; + el.invalid = true; + var $wrapper = $coralSelect.closest(".coral-Form-fieldwrapper"); + $wrapper.find(".cmp-esign-field-error").remove(); + $wrapper.append('' + message + ""); + } + + function clearFieldInvalid($coralSelect) { + var el = $coralSelect[0]; + if (el) el.invalid = false; + $coralSelect.closest(".coral-Form-fieldwrapper").find(".cmp-esign-field-error").remove(); + } + + function wireSubmitValidation(dialog) { + channel.on("cq-dialog-submit.esign", function(e) { + if (!$(dialog).is(":visible")) return; + if (!validateSigningConfig(dialog)) { + e.preventDefault(); + e.stopImmediatePropagation(); + } + }); + } + + function initElectronicSignatureDialog(dialog) { + var $dialog = $(dialog); + if (!$dialog.find(SELECTORS.esignTab).length) { + return; + } + + firstSignerMultifieldItem = null; + + // Register listeners before toggleSigningConfig so the coral-collection:add + // handler is in place before ensureDefaultSigner potentially fires synchronously. + $dialog.on("change", SELECTORS.enableCheckbox, function() { + toggleSigningConfig(dialog); + }); + wireSignerItemListeners(dialog); + wireMultifieldListeners(dialog); + wireSubmitValidation(dialog); + + initAllSignerItems(dialog); + captureFirstSignerMultifieldItem(dialog); + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + toggleSigningConfig(dialog); + + var tabView = dialog.querySelector("coral-tabview"); + if (tabView) { + tabView.addEventListener("coral-tabview:change", function() { + initAllSignerItems(dialog); + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + }); + } + } + + function onDialogContentLoaded(e) { + var $target = $(e.target); + var dialog = $target.closest(".cq-dialog")[0] || $(".cq-dialog:visible")[0]; + if (!dialog || !$(dialog).find(SELECTORS.esignTab).length) { + return; + } + initAllSignerItems(dialog); + captureFirstSignerMultifieldItem(dialog); + updateMultifieldItemChrome(dialog); + updateFirstSignerState(dialog); + ensureDefaultSigner(dialog); + } + + channel.on("foundation-contentloaded", onDialogContentLoaded); + + channel.on("dialog-ready", function() { + var dialog = $(".cq-dialog")[0]; + if (dialog) { + Coral.commons.ready(dialog, function() { + initElectronicSignatureDialog(dialog); + }); + } + }); + +})(jQuery, jQuery(document), Coral);