diff --git a/core/src/main/java/com/exadel/aem/toolkit/api/annotations/meta/ResourceTypes.java b/core/src/main/java/com/exadel/aem/toolkit/api/annotations/meta/ResourceTypes.java index 6d187b418..6ef7e03e2 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/api/annotations/meta/ResourceTypes.java +++ b/core/src/main/java/com/exadel/aem/toolkit/api/annotations/meta/ResourceTypes.java @@ -29,6 +29,7 @@ public class ResourceTypes { public static final String BUTTON = "granite/ui/components/coral/foundation/button"; public static final String BUTTON_GROUP = "granite/ui/components/coral/foundation/form/buttongroup"; public static final String CHECKBOX = "granite/ui/components/coral/foundation/form/checkbox"; + public static final String CODE_EDITOR = "etoolbox-authoring-kit/components/authoring/codeeditor"; public static final String COLORFIELD = "granite/ui/components/coral/foundation/form/colorfield"; public static final String CONTAINER = "granite/ui/components/coral/foundation/container"; public static final String CORAL_FILEUPLOAD = "granite/ui/components/coral/foundation/form/fileupload"; diff --git a/core/src/main/java/com/exadel/aem/toolkit/api/annotations/widgets/codeeditor/CodeEditor.java b/core/src/main/java/com/exadel/aem/toolkit/api/annotations/widgets/codeeditor/CodeEditor.java new file mode 100644 index 000000000..0d3e3f352 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/api/annotations/widgets/codeeditor/CodeEditor.java @@ -0,0 +1,79 @@ +/* + * 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.exadel.aem.toolkit.api.annotations.widgets.codeeditor; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.exadel.aem.toolkit.api.annotations.meta.ResourceType; +import com.exadel.aem.toolkit.api.annotations.meta.ResourceTypes; +import com.exadel.aem.toolkit.api.annotations.meta.ValueRestriction; +import com.exadel.aem.toolkit.api.annotations.meta.ValueRestrictions; + +/** + * Used to set up a syntax-highlighting code editor withing a Touch UI dialog or page. Default implementation is based + * on the open-source Ace Editor + */ + +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@ResourceType(ResourceTypes.CODE_EDITOR) +public @interface CodeEditor { + + /** + * Specifies a particular link to the editor's {@code js} file in an external repository or CDN. Can be used to + * require a specific version or a custom build + * @return Optional string value + */ + @ValueRestriction(ValueRestrictions.NOT_BLANK_OR_DEFAULT) + String source() default ""; + + /** + * Defines the code highlighting completion mode of the editor (the "language" or markup format it works with). Must + * match one of the built-in modes enumerated in the project's + * repository (unless a custom mode is supplied). By default, the {@code json} mode is used + * @return Optional string value + */ + @ValueRestriction(ValueRestrictions.NOT_BLANK_OR_DEFAULT) + String mode() default ""; + + /** + * Defines the visual and code highlighting theme of the editor. Must match one of the built-in themes enumerated in + * the project's repository (unless a + * custom theme is supplied). By default, the {@code crimson_editor} theme is used + * @return Optional string value + */ + @ValueRestriction(ValueRestrictions.NOT_BLANK_OR_DEFAULT) + String theme() default ""; + + /** + * Declares options that will be passed to the {@code CodeEditor} upon initialization. Every option is a key-value + * pair. The option keys originate from the editor's + * API + * @return Optional array of {@link CodeEditorOption} objects + */ + CodeEditorOption[] options() default {}; + + /** + * When set, specifies the string marker prepended to the data stored in JCR for the current field. E.g., if the + * visible value of the field is {@code "Hello World"} and the {@code dataPrefix = "text:"} is specified, to the JCR + * goes {@code "text:Hello World"}. This can be used to distinguish between snippets written in different languages, + * or else to prevent AEM from falsely processing the field value as some Granite content (e.g. when storing JSON + * strings in a multifield) + * @return Optional string value + */ + String dataPrefix() default ""; +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/api/annotations/widgets/codeeditor/CodeEditorOption.java b/core/src/main/java/com/exadel/aem/toolkit/api/annotations/widgets/codeeditor/CodeEditorOption.java new file mode 100644 index 000000000..7ad4179ab --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/api/annotations/widgets/codeeditor/CodeEditorOption.java @@ -0,0 +1,39 @@ +/* + * 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.exadel.aem.toolkit.api.annotations.widgets.codeeditor; + +/** + * Represents a configuration option passed to a {@code CodeEditor} upon initialization + */ +public @interface CodeEditorOption { + + /** + * Defines the name of the option + * @return String value, non-blank + */ + String name(); + + /** + * Defines the value of the option + * @return String value + */ + String value(); + + /** + * Defines the type of the option that will be used with the initialization script. Expected values are {@code + * String} (default), {@code Boolean}, and {@code Integer} + * @return Class reference + */ + Class type() default String.class; +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/CoreConstants.java b/core/src/main/java/com/exadel/aem/toolkit/core/CoreConstants.java index 8ed367ac9..35bbb3d68 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/CoreConstants.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/CoreConstants.java @@ -21,15 +21,18 @@ public class CoreConstants { public static final String NN_LIST = "list"; public static final String PN_APPEND = "append"; + public static final String PN_DISABLED = "disabled"; public static final String PN_ITEM_RESOURCE_TYPE = "itemResourceType"; public static final String PN_LIMIT = "limit"; public static final String PN_LIST_ITEM = "listItem"; public static final String PN_OFFSET = "offset"; public static final String PN_PATH = "path"; public static final String PN_PREPEND = "prepend"; + public static final String PN_REQUIRED = "required"; public static final String PN_SELECTED = "selected"; public static final String PN_TEXT = "text"; public static final String PN_UPDATE_COMPONENT_LIST = "updatecomponentlist"; + public static final String PN_VALIDATION = "validation"; public static final String PN_VALUE = "value"; public static final String PARAMETER_ID = "@id"; diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/BaseModel.java b/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/BaseModel.java index afc9b658b..3119cf625 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/BaseModel.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/BaseModel.java @@ -14,6 +14,7 @@ package com.exadel.aem.toolkit.core.authoring.models; import java.util.Map; +import javax.annotation.PostConstruct; import javax.script.SimpleBindings; import org.apache.commons.lang3.StringUtils; @@ -25,11 +26,18 @@ import org.apache.sling.models.annotations.injectorspecific.SlingObject; import org.apache.sling.models.annotations.injectorspecific.ValueMapValue; import com.adobe.granite.ui.components.AttrBuilder; +import com.adobe.granite.ui.components.Config; +import com.adobe.granite.ui.components.Value; import com.adobe.granite.ui.components.htl.ComponentHelper; import com.exadel.aem.toolkit.api.annotations.main.AemComponent; import com.exadel.aem.toolkit.core.CoreConstants; +/** + * Presents the basic logic for rendering Granite UI components with the use of Sling models and HTL markup. This class + * manages only the common component properties and is expected to be extended with component-specific Sling models for + * all cases but the most basic ones + */ @Model(adaptables = SlingHttpServletRequest.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL) @AemComponent( path = "components/authoring/base", @@ -38,6 +46,9 @@ ) public class BaseModel { + private static final String ATTRIBUTE_ARIA_REQUIRED = "aria-required"; + private static final String ATTRIBUTE_FOUNDATION_VALIDATION = "data-foundation-validation"; + @SlingObject private SlingHttpServletRequest request; @@ -46,14 +57,35 @@ public class BaseModel { private LocalComponentHelper componentHelper; + /** + * Performs post-inject model initialization + */ + @PostConstruct + private void init() { + componentHelper = new LocalComponentHelper(request); + } + + /** + * Retrieves common attributes of a Granit UI component such as {@code required}, {@code disabled}, etc. These + * attributes are typically rendered to the main container of the component's HTML markup + * @return Map of string values + */ public Map getCommonAttributes() { - return getComponentHelper().getCommonAttributes(); + return componentHelper.getCommonAttributes(); } + /** + * Retrieves the value of the {@code name} attribute of the editable component + * @return String value; a non-blank string is expected + */ public String getName() { return name; } + /** + * Retrieves the value of the editable component + * @return A nullable string value + */ public Object getValue() { if (request == null || request.getRequestPathInfo().getSuffixResource() == null @@ -61,54 +93,90 @@ public Object getValue() { return getDefaultValue(); } - Resource suffixResource = request.getRequestPathInfo().getSuffixResource(); - String relativePath = name.contains(CoreConstants.SEPARATOR_SLASH) - ? StringUtils.substringBeforeLast(name, CoreConstants.SEPARATOR_SLASH) - : StringUtils.EMPTY; + Resource dataResource = getDataResource(); + Config config = new Config(dataResource); + Value value = new Value(request, config); + String propertyName = name.contains(CoreConstants.SEPARATOR_SLASH) ? StringUtils.substringAfterLast(name, CoreConstants.SEPARATOR_SLASH) : name; - - Resource endResource = StringUtils.isNotBlank(relativePath) - ? request.getResourceResolver().getResource(suffixResource, relativePath) - : suffixResource; - if (endResource == null) { - return getDefaultValue(); - } - - return endResource.getValueMap().get(propertyName); + return value.get(CoreConstants.RELATIVE_PATH_PREFIX + propertyName); } - + /** + * Retrieves the value which is rendered when no user-defined value is set for this editable component + * @return A nullable string value + */ public Object getDefaultValue() { return null; } - private LocalComponentHelper getComponentHelper() { - if (componentHelper == null) { - componentHelper = new LocalComponentHelper(request); - } - return componentHelper; + /** + * Retrieves the {@link Resource} that contains data for the current component + * @return {@code Resource} object + */ + private Resource getDataResource() { + Resource suffixResource = request.getRequestPathInfo().getSuffixResource(); + String relativePath = name.contains(CoreConstants.SEPARATOR_SLASH) + ? StringUtils.substringBeforeLast(name, CoreConstants.SEPARATOR_SLASH) + : StringUtils.EMPTY; + + return StringUtils.isNotBlank(relativePath) + ? request.getResourceResolver().getResource(suffixResource, relativePath) + : suffixResource; } + /** + * Implements {@link ComponentHelper} to provide common HTML attributes for the Granite UI components rendered with + * Sling models and HTL + */ private static class LocalComponentHelper extends ComponentHelper { + private SlingHttpServletRequest request; + /** + * Creates a new {@link ComponentHelper} instance + * @param request {@code SlingHttpServletRequest} used to construct the current component + */ public LocalComponentHelper(SlingHttpServletRequest request) { if (request == null) { return; } + this.request = request; init(new SimpleBindings((SlingBindings) request.getAttribute(SlingBindings.class.getName()))); } + /** + * Retrieves common HTML attributes for the Granite UI components + * @return A non-null {@code Map} + */ public Map getCommonAttributes() { AttrBuilder attrBuilder = getInheritedAttrs(); populateCommonAttrs(attrBuilder); - return attrBuilder.getData(); + Map result = attrBuilder.getData(); + if (request == null) { + return result; + } + if (request.getResource().getValueMap().get(CoreConstants.PN_DISABLED, false)) { + result.put(CoreConstants.PN_DISABLED, Boolean.TRUE.toString()); + } + if (request.getResource().getValueMap().get(CoreConstants.PN_REQUIRED, false)) { + result.put(ATTRIBUTE_ARIA_REQUIRED, Boolean.TRUE.toString()); + } + String validation = StringUtils.join( + CoreConstants.SEPARATOR_COMMA, + request.getResource().getValueMap().get(CoreConstants.PN_VALIDATION, String[].class)); + if (StringUtils.isNotBlank(validation)) { + result.put(ATTRIBUTE_FOUNDATION_VALIDATION, validation); + } + return result; } + /** + * A required stub method according the {@code ComponentHelper} contract + */ @Override protected void activate() { - // Not implemented + // Not Implemented } } } diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/CodeEditor.java b/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/CodeEditor.java new file mode 100644 index 000000000..e6fd2ce90 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/CodeEditor.java @@ -0,0 +1,114 @@ +/* + * 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.exadel.aem.toolkit.core.authoring.models; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.models.annotations.Default; +import org.apache.sling.models.annotations.DefaultInjectionStrategy; +import org.apache.sling.models.annotations.Model; +import org.apache.sling.models.annotations.injectorspecific.ValueMapValue; + +import com.exadel.aem.toolkit.api.annotations.main.AemComponent; + +/** + * Extends {@link BaseModel} to provide additional facilities for rendering {@link + * com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor}-defined widgets in Granite UI + */ +@Model(adaptables = SlingHttpServletRequest.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL) +@AemComponent( + path = "components/authoring/codeeditor", + title = "CodeEditor", + resourceSuperType = "etoolbox-authoring-kit/components/authoring/base" +) +public class CodeEditor extends BaseModel { + + private static final String DEFAULT_THEME = "crimson_editor"; + private static final String DEFAULT_MODE = "json"; + + @ValueMapValue + private String source; + + @ValueMapValue + @Default(values = DEFAULT_THEME) + private String theme; + + @ValueMapValue + @Default(values = DEFAULT_MODE) + private String mode; + + @ValueMapValue + private String options; + + @ValueMapValue + private String dataPrefix; + + /** + * {@inheritDoc} + */ + @Override + public Object getValue() { + String result = ObjectUtils.defaultIfNull(super.getValue(), StringUtils.EMPTY).toString(); + if (StringUtils.isNotBlank(dataPrefix) && StringUtils.startsWith(result, dataPrefix)) { + return result.substring(dataPrefix.length()); + } + return result; + } + + /** + * Retrieves the {@code source} value as defined by the user for the current component + * @return A nullable string value + * @see com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor#source() + */ + public String getSource() { + return source; + } + + /** + * Retrieves the {@code theme} value as defined by the user for the current component + * @return A nullable string value + * @see com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor#theme() + */ + public String getTheme() { + return theme; + } + + /** + * Retrieves the language {@code mode} value as defined by the user for the current component + * @return A nullable string value; can be empty + * @see com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor#mode() + */ + public String getMode() { + return mode; + } + + /** + * Retrieves initialization options for the current component + * @return A nullable string value + * @see com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor#options() + */ + public String getOptions() { + return options; + } + + /** + * Retrieves the {@code dataPrefix} value as defined by the user for the current component + * @return A nullable string value + * @see com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor#dataPrefix() () + */ + public String getDataPrefix() { + return dataPrefix; + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/IgnoreFreshnessToggler.java b/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/IgnoreFreshnessToggler.java index 11be0475e..0925dfd11 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/IgnoreFreshnessToggler.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/authoring/models/IgnoreFreshnessToggler.java @@ -39,7 +39,7 @@ public class IgnoreFreshnessToggler { private SlingHttpServletRequest request; /** - * Sets or unsets the {@code forceIgnoreFreshness} flag to the Sling HTTP requests upon this Sling model initialization + * Performs post-inject model initialization */ @PostConstruct private void init() { diff --git a/docs/content/dev-tools/component-management/dialog-fields/widgets.md b/docs/content/dev-tools/component-management/dialog-fields/widgets.md index 540b19aa8..2f05f93f9 100644 --- a/docs/content/dev-tools/component-management/dialog-fields/widgets.md +++ b/docs/content/dev-tools/component-management/dialog-fields/widgets.md @@ -226,6 +226,45 @@ public class NestedCheckboxListDialog { } ``` +### CodeEditor +* Resource type: /apps/etoolbox-authoring-kit/components/authoring/codeeditor" + +Used to render a customizable code editor with syntax highlighting, autocompletion, and similar facilities inside the Granite UI. + +Current implementation is based on the freeware open-source [Ace Editor](https://ace.c9.io/). + +You can set up the code editor in your dialog as follows: +```java +@Dialog +public class CodeEditorDialog { + @DialogField + @CodeEditor( + source = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.10.0/ace.js", + mode = "js", + theme = "crimson_editor", + options = { + @CodeEditorOption(name = "wrap", value = "true", type = boolean.class), + @CodeEditorOption(name = "maxLines", value = "Infinity"), + } + ) + private String code; +} + +``` +_All_ of the properties of the `@CodeEditor` are optional and have meaningful defaults. + +You can specify _source_ to make sure that the code editor's library is fetched from the particular URL. This can be a specific version or a modified build of a library, e.g., packed with specific plugins. (Note: the source is selected once per a dialog; you cannot specify different sources for different instances of code editor within the same Touch UI experience). + +The _mode_ property is responsible for the "language" or markup the current editor supports. See [this link](https://github.com/ajaxorg/ace-builds/tree/master/src-noconflict) for the common variants. Default mode is `json`. + +The _theme_ property defines the graphic theme. See [link](https://github.com/ajaxorg/ace-builds/tree/master/src-noconflict) for available options. Default is the "_crimson_editor_" theme. + +_Options_ are essentially a set of key-value pairs used to initialize and profile an instance of the editor for different needs. You can specify keys and values that follow the [API reference](https://ajaxorg.github.io/ace-api-docs/index.html). Different instanced of the editor can have different modes, themes, and options even whet situated in the same dialog. + +There's also the possibility to set up _dataPrefix_. This is a rather rarely used setting that helps to differentiate code snippets as stored in the JCR and also prevents false handling of a value by the Granite engine. (For instance, if you would like to save a collection of JSON values inside a multifield, Granite could potentially "misunderstand" them for the JSON-encoded contents of the multifield itself, which would lead to a bad display. The use of prefixes helps to "escape" such values.) + +_Note:_ we do not modify, adapt, support, or redistribute the code of _Ace Editor_. We use it as-is by referencing a publicly available version of the code directly from the Internet. User experience can change with the release of a new version of _Ace Editor_ which we don't manage or control. + ### ColorField * [@ColorField](https://javadoc.io/doc/com.exadel.etoolbox/etoolbox-authoring-kit-core/latest/com/exadel/aem/toolkit/api/annotations/widgets/color/ColorField.html) diff --git a/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/CodeEditorHandler.java b/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/CodeEditorHandler.java new file mode 100644 index 000000000..2330e15e7 --- /dev/null +++ b/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/CodeEditorHandler.java @@ -0,0 +1,139 @@ +/* + * 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.exadel.aem.toolkit.plugin.handlers.widgets; + +import java.io.IOException; +import java.util.Set; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.StringUtils; + +import com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor; +import com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditorOption; +import com.exadel.aem.toolkit.api.handlers.Handler; +import com.exadel.aem.toolkit.api.handlers.Handles; +import com.exadel.aem.toolkit.api.handlers.Source; +import com.exadel.aem.toolkit.api.handlers.Target; +import com.exadel.aem.toolkit.plugin.maven.PluginRuntime; +import com.exadel.aem.toolkit.plugin.utils.DialogConstants; +import com.exadel.aem.toolkit.plugin.utils.StringUtil; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; + +/** + * Implements {@code BiConsumer} to populate a {@link Target} instance with properties originating from a {@link Source} + * object that define the {@code CodeEditor} widget look and behavior + */ +@Handles(CodeEditor.class) +public class CodeEditorHandler implements Handler { + + private static final String CLIENTLIB_CATEGORY_EDITOR = "eak.widgets.editor"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .registerModule(new SimpleModule() + .addSerializer(CodeEditorOption[].class, new CodeEditorOptionsSerializer())); + + /** + * Processes data that can be extracted from the given {@code Source} and stores it into the provided {@code + * Target} + * @param source {@code Source} object used for data retrieval + * @param target Resulting {@code Target} object + */ + @Override + public void accept(Source source, Target target) { + CodeEditor codeEditor = source.adaptTo(CodeEditor.class); + target.attributes(codeEditor); + populateClientLibrary(target); + if (ArrayUtils.isEmpty(codeEditor.options())) { + return; + } + try { + String options = OBJECT_MAPPER.writeValueAsString(codeEditor.options()); + target.attribute(DialogConstants.NN_OPTIONS, escapeJson(options)); + } catch (JsonProcessingException e) { + PluginRuntime.context().getExceptionHandler().handle(e); + } + } + + /** + * Conditionally stores the reference to the {@code eak.widgets.editor} client library to the current dialog + * @param target Resulting {@code Target} object + */ + private static void populateClientLibrary(Target target) { + Target dialogRoot = target.findParent(t -> DialogConstants.NN_ROOT.equals(t.getName())); + if (dialogRoot == null) { + return; + } + String extraClientlibs = dialogRoot.getAttribute(DialogConstants.PN_EXTRA_CLIENTLIBS); + if (StringUtils.isEmpty(extraClientlibs)) { + dialogRoot.attribute(DialogConstants.PN_EXTRA_CLIENTLIBS, new String[]{CLIENTLIB_CATEGORY_EDITOR}); + } else { + Set extraClientlibSet = StringUtil.parseSet(extraClientlibs); + extraClientlibSet.add(CLIENTLIB_CATEGORY_EDITOR); + dialogRoot.attribute(DialogConstants.PN_EXTRA_CLIENTLIBS, StringUtil.format(extraClientlibSet, String.class)); + } + } + + /** + * Prepares the provided JSON string for storing as an XML attribute value + * @param value A non-null JSON string + * @return String value + */ + private static String escapeJson(String value) { + return value.replace("{", "\\{").replace("}", "\\}"); + } + + /** + * Represents {@link JsonSerializer} for storing the configuration set up via {@link CodeEditor} in the content + * repository + */ + private static class CodeEditorOptionsSerializer extends JsonSerializer { + + /** + * Retrieves a JSON render of the provided {@code CodeEditor} annotation + * @param options An array of user-specified {@link CodeEditorOption} values + * @param jsonGenerator Managed {@code JsonGenerator} object + * @param serializerProvider Managed {@code SerializerProvider} object + * @throws IOException if the serialization fails + */ + @Override + public void serialize( + CodeEditorOption[] options, + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider) throws IOException { + + jsonGenerator.writeStartObject(); + for (CodeEditorOption option : options) { + if (option.type().equals(Boolean.class) || option.type().equals(boolean.class)) { + jsonGenerator.writeBooleanField(option.name(), Boolean.parseBoolean(option.value())); + } else if ( + StringUtils.isNumeric(option.value()) + && (ClassUtils.primitiveToWrapper(option.type()).equals(Integer.class) + || ClassUtils.primitiveToWrapper(option.type()).equals(Long.class)) + ) { + jsonGenerator.writeNumberField(option.name(), Long.parseLong(option.value())); + } else { + jsonGenerator.writeStringField(option.name(), option.value()); + } + } + jsonGenerator.writeEndObject(); + } + } +} diff --git a/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/common/MultipleAnnotationHandler.java b/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/common/MultipleAnnotationHandler.java index fe6bba4f5..73ad6520f 100644 --- a/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/common/MultipleAnnotationHandler.java +++ b/plugin/src/main/java/com/exadel/aem/toolkit/plugin/handlers/widgets/common/MultipleAnnotationHandler.java @@ -220,11 +220,11 @@ private static Map getTransferPolicies(Source so .forEach(property -> transferPolicies.put(CoreConstants.SEPARATOR_AT + property.name(), PropertyTransferPolicy.LEAVE_IN_MULTIFIELD)); // However, we need to override policies for some properties that have been stored in a loop above transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.PN_NAME, PropertyTransferPolicy.MOVE_TO_NESTED_NODE); - transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.PN_REQUIRED, PropertyTransferPolicy.MOVE_TO_NESTED_NODE); + transferPolicies.put(CoreConstants.SEPARATOR_AT + CoreConstants.PN_REQUIRED, PropertyTransferPolicy.MOVE_TO_NESTED_NODE); transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.PN_WRAPPER_CLASS, PropertyTransferPolicy.MOVE_TO_NESTED_NODE); // Some attribute values are expected to be moved or copied though have set to "skipped" above transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.PN_PRIMARY_TYPE, PropertyTransferPolicy.COPY_TO_NESTED_NODE); - transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.PN_DISABLED, PropertyTransferPolicy.COPY_TO_NESTED_NODE); + transferPolicies.put(CoreConstants.SEPARATOR_AT + CoreConstants.PN_DISABLED, PropertyTransferPolicy.COPY_TO_NESTED_NODE); transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.PN_RENDER_HIDDEN, PropertyTransferPolicy.COPY_TO_NESTED_NODE); // Rest of element attributes will move to the inner node transferPolicies.put(CoreConstants.SEPARATOR_AT + DialogConstants.WILDCARD, PropertyTransferPolicy.MOVE_TO_NESTED_NODE); diff --git a/plugin/src/main/java/com/exadel/aem/toolkit/plugin/utils/DialogConstants.java b/plugin/src/main/java/com/exadel/aem/toolkit/plugin/utils/DialogConstants.java index 861fa8c4e..9dde4ec36 100644 --- a/plugin/src/main/java/com/exadel/aem/toolkit/plugin/utils/DialogConstants.java +++ b/plugin/src/main/java/com/exadel/aem/toolkit/plugin/utils/DialogConstants.java @@ -109,11 +109,11 @@ public class DialogConstants { public static final String PN_DEPENDS_ON_REF = "dependsOnRef"; public static final String PN_DEPENDS_ON_REFTYPE = "dependsOnRefType"; public static final String PN_DEPENDS_ON_REFLAZY = "dependsOnRefLazy"; - public static final String PN_DISABLED = "disabled"; public static final String PN_DISCONNECTED = "disconnected"; public static final String PN_EDIT_ELEMENT_QUERY = "editElementQuery"; public static final String PN_EDITOR_TYPE = "editorType"; public static final String PN_EXTERNAL_STYLESHEETS = "externalStyleSheets"; + public static final String PN_EXTRA_CLIENTLIBS = "extraClientlibs"; public static final String PN_FALLBACK_BLOCK_TAG = "fallbackBlockTag"; public static final String PN_FALLBACK_PATH = "fallbackPath"; public static final String PN_FEATURES = "features"; @@ -133,7 +133,6 @@ public class DialogConstants { public static final String PN_PROTOCOLS = "protocols"; public static final String PN_REF = "ref"; public static final String PN_RENDER_HIDDEN = "renderHidden"; - public static final String PN_REQUIRED = "required"; public static final String PN_RETYPE = "retype"; public static final String PN_SLING_RESOURCE_TYPE = "sling:resourceType"; public static final String PN_TAB_SIZE = "tabSize"; diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/WidgetsTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/WidgetsTest.java index ef26b8ead..86224379d 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/WidgetsTest.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/WidgetsTest.java @@ -23,6 +23,7 @@ import com.exadel.aem.toolkit.test.widget.AnchorButtonWidget; import com.exadel.aem.toolkit.test.widget.ButtonGroupWidget; import com.exadel.aem.toolkit.test.widget.ButtonWidget; +import com.exadel.aem.toolkit.test.widget.CodeEditorWidget; import com.exadel.aem.toolkit.test.widget.ColorFieldWidget; import com.exadel.aem.toolkit.test.widget.DatePickerWidget; import com.exadel.aem.toolkit.test.widget.FieldSetWidget; @@ -82,6 +83,11 @@ public void testButtonGroup() { test(ButtonGroupWidget.class); } + @Test + public void testCodeEditor() { + test(CodeEditorWidget.class); + } + @Test public void testColorFieldAndHtmlTag() { test(ColorFieldWidget.class); diff --git a/plugin/src/test/com/exadel/aem/toolkit/test/widget/CodeEditorWidget.java b/plugin/src/test/com/exadel/aem/toolkit/test/widget/CodeEditorWidget.java new file mode 100644 index 000000000..4ea727979 --- /dev/null +++ b/plugin/src/test/com/exadel/aem/toolkit/test/widget/CodeEditorWidget.java @@ -0,0 +1,43 @@ +/* + * 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.exadel.aem.toolkit.test.widget; + +import com.exadel.aem.toolkit.api.annotations.main.AemComponent; +import com.exadel.aem.toolkit.api.annotations.main.Dialog; +import com.exadel.aem.toolkit.api.annotations.widgets.DialogField; +import com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditor; +import com.exadel.aem.toolkit.api.annotations.widgets.codeeditor.CodeEditorOption; +import com.exadel.aem.toolkit.plugin.utils.TestConstants; + +@AemComponent( + path = TestConstants.DEFAULT_COMPONENT_NAME, + title = TestConstants.DEFAULT_COMPONENT_TITLE +) +@Dialog(extraClientlibs = {"eak.library.1", "eak.library.2"}) +@SuppressWarnings("unused") +public class CodeEditorWidget { + + @DialogField + @CodeEditor( + source = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.10.0/ace.js", + mode = "js", + theme = "crimson_editor", + options = { + @CodeEditorOption(name = "wrap", value = "true", type = boolean.class), + @CodeEditorOption(name = "maxLines", value = "Infinity"), + }, + dataPrefix = "js:" + ) + String code; +} diff --git a/plugin/src/test/resources/widget/codeEditor/.content.xml b/plugin/src/test/resources/widget/codeEditor/.content.xml new file mode 100644 index 000000000..69644536e --- /dev/null +++ b/plugin/src/test/resources/widget/codeEditor/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/plugin/src/test/resources/widget/codeEditor/_cq_dialog.xml b/plugin/src/test/resources/widget/codeEditor/_cq_dialog.xml new file mode 100644 index 000000000..695434d9e --- /dev/null +++ b/plugin/src/test/resources/widget/codeEditor/_cq_dialog.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/base/render.jsp b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/base/render.jsp index 9fc8da89b..3287a3fb5 100644 --- a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/base/render.jsp +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/base/render.jsp @@ -11,7 +11,6 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --%> - <%@ include file="/libs/granite/ui/global.jsp" %> <%@ page session="false" import="com.adobe.granite.ui.components.ComponentHelper.Options" %> <% diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/.content.xml b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/.content.xml new file mode 100644 index 000000000..c796cf68a --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/.content.xml @@ -0,0 +1,5 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/css.txt b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/css.txt new file mode 100644 index 000000000..e8c7365cf --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/css.txt @@ -0,0 +1 @@ +style.css diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/js.txt b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/js.txt new file mode 100644 index 000000000..06f56a3df --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/js.txt @@ -0,0 +1 @@ +script.js diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/script.js b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/script.js new file mode 100644 index 000000000..abaadd9ec --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/script.js @@ -0,0 +1,196 @@ +/* + * 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. + */ +'use strict'; + +(function (window, document, $, Granite) { + const DEFAULT_SCRIPT_SOURCE = 'https://ajaxorg.github.io/ace-builds/src-min-noconflict/ace.js'; + const EDITOR_SELECTOR = '.eak-editor'; + const VALIDATION_MESSAGE = 'Error: Please fill out this field.'; + + $(document).on('foundation-contentloaded', init); + + /** + * Registers the editor host for use with Granite UI and retrieves the Ace Editor script + */ + function init() { + registerFieldAdapter(); + registerValidator(); + registerValueHook(); + + const scriptSrcAttribute = $(EDITOR_SELECTOR + '[data-source]').map((index, element) => $(element).data('source')); + const scriptSrc = scriptSrcAttribute && scriptSrcAttribute.length ? scriptSrcAttribute[0] : DEFAULT_SCRIPT_SOURCE; + $.getScript(scriptSrc) + .done(appendEditors) + .fail(function (jqxhr, settings, ex) { + console.error('Could not retrieve the editor script', ex); + }); + } + + /** + * Registers the editor host as a {@code foundation-field} entity in the global Granite UI registry + */ + function registerFieldAdapter() { + $(window).adaptTo('foundation-registry').register('foundation.adapters', { + type: 'foundation-field', + selector: EDITOR_SELECTOR, + adapter: function (el) { + const input = $(el).find('input')[0]; + return { + getName: function () { + return input.name; + }, + setName: function (name) { + input.name = name; + }, + isDisabled: function () { + return el.disabled; + }, + setDisabled: function (disabled) { + el.disabled = disabled; + el.editor && el.editor.setReadOnly(disabled || this.isReadOnly()); + }, + isInvalid: function () { + return $(el).attr('aria-invalid') === 'true'; + }, + setInvalid: function (invalid) { + $(el).attr('aria-invalid', invalid ? 'true' : 'false'); + $(el).toggleClass('is-invalid', invalid); + }, + isReadOnly: function () { + return $(el).attr('readonly'); + }, + setReadOnly: function (readOnly) { + $(el).attr('readonly', readOnly); + el.editor && el.editor.setReadOnly(readOnly || this.isDisabled()); + }, + isRequired: function () { + return $(el).attr('aria-required') === 'true'; + }, + setRequired: function (required) { + $(el).attr('aria-required', required ? 'true' : 'false'); + }, + getValue: function () { + return el.editor && el.editor.getValue(); + }, + setValue: function (value) { + el.editor && el.editor.setValue(value); + }, + getLabelledBy: function () { + return $(el).attr('aria-labelledby'); + }, + setLabelledBy: function (labelledBy) { + $(el).attr('aria-labelledby', labelledBy); + }, + getValues: function () { + return [el.value]; + }, + setValues: function (values) { + el.value = values[0]; + }, + clear: function () { + el.editor && el.editor.setValue(''); + } + }; + } + }); + + $(document).on('change', EDITOR_SELECTOR, function () { + $(this).trigger('foundation-field-change'); + }); + } + + /** + * Registers the editor host in the global Granite UI registry as subject to validation + */ + function registerValidator() { + const registry = $(window).adaptTo('foundation-registry'); + registry.register('foundation.validation.selector', { + submittable: EDITOR_SELECTOR, + candidate: EDITOR_SELECTOR + ':not([readonly]):not([disabled])', + exclusion: EDITOR_SELECTOR + ' *' + }); + registry.register('foundation.validation.validator', { + selector: EDITOR_SELECTOR, + validate: function (el) { + const isRequired = $(el).attr('aria-required') === 'true'; + if (!isRequired) { + return; + } + return !el.editor || el.editor.getValue().length > 0 ? undefined : Granite.I18n.get(VALIDATION_MESSAGE); + } + }); + } + + /** + * For all the editor hosts existing in the current dialog defines the {@code value} property. This one is used + * in property accessors, such as {@code DependsOn}'s + */ + function registerValueHook() { + $(EDITOR_SELECTOR).each(function () { + const editorHost = this; + Object.defineProperty(this, 'value', { + get() { + if (editorHost.editor) { + return editorHost.editor.getValue(); + } else { + return editorHost.textContent; + } + }, + set(value) { + if (editorHost.editor) { + editorHost.editor.setValue(value); + } else { + editorHost.textContent = value; + } + }, + configurable: true + }); + }); + } + + /** + * For every editor hosts existing in the current dialog initializes an instance of Ace Editor and sets initial + * options + */ + function appendEditors() { + $(EDITOR_SELECTOR).each(function () { + const $this = $(this); + const $input = $this.siblings('input'); + + const editor = window.ace.edit(this); + $this.data('theme') && editor.setTheme($this.data('theme')); + $this.data('mode') && editor.session.setMode($this.data('mode')); + + const options = $this.data('options'); + if (options) { + Object.keys(options).forEach(key => { + if (options[key] === 'Infinity') { + options[key] = Infinity; + } + }); + editor.setOptions(options); + } + + if ($(this).attr('disabled') || $(this).attr('readonly')) { + editor.setReadOnly(true); + } + + editor.on('change', function () { + $input.val(($this.data('prefix') || '') + editor.getValue()); + }); + + this.editor = editor; + }); + } +})(window, document, Granite.$, Granite); diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/style.css b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/style.css new file mode 100644 index 000000000..0029db79e --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/clientlibs/style.css @@ -0,0 +1,14 @@ +.eak-editor { + width: 100%; + min-height: 100px; + font-size: 15px; + margin: 8px 0; +} + +.eak-editor.is-invalid { + border: 1px solid #e14132; + } + +.eak-editor[disabled] { + background-color: #f5f5f5; +} diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/render.html b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/render.html new file mode 100644 index 000000000..bcd4cb054 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/components/authoring/codeeditor/render.html @@ -0,0 +1,23 @@ + + +
${model.value}
+ + diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/depends-on/js/dependsOnElementAccessors.js b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/depends-on/js/dependsOnElementAccessors.js index d47638fe4..99a1239de 100644 --- a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/depends-on/js/dependsOnElementAccessors.js +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/depends-on/js/dependsOnElementAccessors.js @@ -47,7 +47,13 @@ notify && $el.trigger('change'); }, readonly: function ($el, state) { - $el.attr('readonly', state ? 'true' : null); + const fieldApi = $el.adaptTo('foundation-field'); + if (fieldApi && typeof fieldApi.setReadOnly === 'function') { + fieldApi.setReadOnly(state); + } else { + $el.attr('readonly', state ? 'true' : null); + } + ns.ElementAccessors.updateValidity($el, true); }, required: function ($el, val) { const fieldApi = $el.adaptTo('foundation-field');