ADOBE EXPERIENCE MANAGER (AEM) COMPONENTS

Message Component

AEM tutorial for development of a message component to allow an AEM author to add message banners to a page. Features include a field to set how many days before the message will reappear after it has been closed and a toggle switch to enable or disable the message.

Getting Started

Create the project using the AEM Project Archetype which is a Maven template that creates a minimal, best-practices-based Adobe Experience Manager (AEM) project. For the message component, I’m using the latest version which is 23 as of this writing.

For new components, typically I will start off by copying the helloworld component included with the archetype. e.g.,

cd ui.apps/src/main/content/jcr_root/apps/myproject

cp components/helloworld components/message

After updating our helloworld copy, renaming properties, etc. for the new message component, here is what we have.

message/.content.xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="cq:Component"
    jcr:title="Message"
    componentGroup="My Project - Content"/>
message/message.html
<div class="cmp-message" data-cmp-is="message">
</div>

<sly data-sly-use.templates="/apps/core/wcm/components/commons/v1/templates.html"
     data-sly-call="${templates.placeholder @ isEmpty = !properties.text}"></sly>

We added a placeholder template call at the bottom so the component can be selected by an author for editing when it does not have any content.

Dialog

We’re going to want tabs in our message component dialog. To add these, replace the column element with a tabs element. e.g.,

message/_cq_dialog/.content.xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
    jcr:primaryType="nt:unstructured"
    jcr:title="Properties"
    sling:resourceType="cq/gui/components/authoring/dialog">
    <content
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns">
        <items jcr:primaryType="nt:unstructured">
            <tabs
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/tabs"
                maximized="{Boolean}true">
                <items jcr:primaryType="nt:unstructured">
                    <text
                        jcr:primaryType="nt:unstructured"
                        sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                        fieldLabel="Text"
                        name="./text"/>
                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

Our message dialog is going to use the v2 text dialog element to give the author a rich text editing experience for the message content.

Replace the existing message text element with a copy of the text element in core/wcm/components/text/v2/text/_cq_dialog/.content.xml

Then give the tab a title by adding a jcr:title to the text element. e.g.,

<text
    jcr:primaryType="nt:unstructured"
    jcr:title="Text"

While we’re at it, let’s also change the title of the dialog itself from Properties to Message. In the jcr:root element at the top of the .content.xml, update the jcr:title with “Message”. e.g.,

<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
    jcr:primaryType="nt:unstructured"
    jcr:title="Message"
    sling:resourceType="cq/gui/components/authoring/dialog">

HTL

Now let’s update our template to display the text content that is entered by an author.

message/message.html
<div class="cmp-message" data-cmp-is="message">
    <div>${properties.text @ context='html'}</div>
</div>
...

Install

Install our new component from the archetype project into the running AEM instance using Maven.

You can use mvn clean install to build and deploy it. e.g.,

mvn -PautoInstallPackage clean install

After dropping the component onto a page, add some content. e.g.,

message component dialog rich text edit

Next, let’s provide a button so the use can dismiss the message. Also, add a data-cmp-hook-message="text" data attribute to the div containing the message text. The component JavaScript will use this as a selector.

message/message.html
<div class="cmp-message" data-cmp-is="message">
    <div data-cmp-hook-message="text">${properties.text @ context='html'}</div>
    <button>close</button>
</div>
...

Toggle Switch

The last step for the dialog is to add the Enable tab. Directly after the text element, add the enable element from this message/_cq_dialog/.content.xml. The properties element is the next tab for the toggle switch that enables rendering and the inout for the number of days to hide.

Here is how the completed message dialog .content.xml should look:

message/_cq_dialog/.content.xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
    jcr:primaryType="nt:unstructured"
    jcr:title="Message"
    sling:resourceType="cq/gui/components/authoring/dialog">
    <content
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns">
        <items jcr:primaryType="nt:unstructured">
            <tabs
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/tabs"
                maximized="{Boolean}true">
                <items jcr:primaryType="nt:unstructured">
                    <text
                        jcr:primaryType="nt:unstructured"
                        jcr:title="Text"
                        sling:resourceType="cq/gui/components/authoring/dialog/richtext"
                        name="./text"
                        useFixedInlineToolbar="{Boolean}true">
                        <rtePlugins jcr:primaryType="nt:unstructured">
                            <format
                                jcr:primaryType="nt:unstructured"
                                features="bold,italic"/>
                            <justify
                                jcr:primaryType="nt:unstructured"
                                features="-"/>
                            <links
                                jcr:primaryType="nt:unstructured"
                                features="modifylink,unlink"/>
                            <lists
                                jcr:primaryType="nt:unstructured"
                                features="*"/>
                            <misctools jcr:primaryType="nt:unstructured">
                                <specialCharsConfig jcr:primaryType="nt:unstructured">
                                    <chars jcr:primaryType="nt:unstructured">
                                        <default_copyright
                                            jcr:primaryType="nt:unstructured"
                                            entity="&amp;copy;"
                                            name="copyright"/>
                                        <default_euro
                                            jcr:primaryType="nt:unstructured"
                                            entity="&amp;euro;"
                                            name="euro"/>
                                        <default_registered
                                            jcr:primaryType="nt:unstructured"
                                            entity="&amp;reg;"
                                            name="registered"/>
                                        <default_trademark
                                            jcr:primaryType="nt:unstructured"
                                            entity="&amp;trade;"
                                            name="trademark"/>
                                    </chars>
                                </specialCharsConfig>
                            </misctools>
                            <paraformat
                                jcr:primaryType="nt:unstructured"
                                features="*">
                                <formats jcr:primaryType="nt:unstructured">
                                    <default_p
                                        jcr:primaryType="nt:unstructured"
                                        description="Paragraph"
                                        tag="p"/>
                                    <default_h1
                                        jcr:primaryType="nt:unstructured"
                                        description="Heading 1"
                                        tag="h1"/>
                                    <default_h2
                                        jcr:primaryType="nt:unstructured"
                                        description="Heading 2"
                                        tag="h2"/>
                                    <default_h3
                                        jcr:primaryType="nt:unstructured"
                                        description="Heading 3"
                                        tag="h3"/>
                                    <default_h4
                                        jcr:primaryType="nt:unstructured"
                                        description="Heading 4"
                                        tag="h4"/>
                                    <default_h5
                                        jcr:primaryType="nt:unstructured"
                                        description="Heading 5"
                                        tag="h5"/>
                                    <default_h6
                                        jcr:primaryType="nt:unstructured"
                                        description="Heading 6"
                                        tag="h6"/>
                                    <default_blockquote
                                        jcr:primaryType="nt:unstructured"
                                        description="Quote"
                                        tag="blockquote"/>
                                    <default_pre
                                        jcr:primaryType="nt:unstructured"
                                        description="Preformatted"
                                        tag="pre"/>
                                </formats>
                            </paraformat>
                            <table
                                jcr:primaryType="nt:unstructured"
                                features="-">
                                <hiddenHeaderConfig
                                    jcr:primaryType="nt:unstructured"
                                    hiddenHeaderClassName="cq-wcm-foundation-aria-visuallyhidden"
                                    hiddenHeaderEditingCSS="cq-RichText-hiddenHeader--editing"/>
                            </table>
                            <tracklinks
                                jcr:primaryType="nt:unstructured"
                                features="*"/>
                        </rtePlugins>
                        <uiSettings jcr:primaryType="nt:unstructured">
                            <cui jcr:primaryType="nt:unstructured">
                                <inline
                                    jcr:primaryType="nt:unstructured"
                                    toolbar="[format#bold,format#italic,format#underline,#justify,#lists,links#modifylink,links#unlink,#paraformat]">
                                    <popovers jcr:primaryType="nt:unstructured">
                                        <justify
                                            jcr:primaryType="nt:unstructured"
                                            items="[justify#justifyleft,justify#justifycenter,justify#justifyright]"
                                            ref="justify"/>
                                        <lists
                                            jcr:primaryType="nt:unstructured"
                                            items="[lists#unordered,lists#ordered,lists#outdent,lists#indent]"
                                            ref="lists"/>
                                        <paraformat
                                            jcr:primaryType="nt:unstructured"
                                            items="paraformat:getFormats:paraformat-pulldown"
                                            ref="paraformat"/>
                                    </popovers>
                                </inline>
                                <tableEditOptions
                                    jcr:primaryType="nt:unstructured"
                                    toolbar="[table#insertcolumn-before,table#insertcolumn-after,table#removecolumn,-,table#insertrow-before,table#insertrow-after,table#removerow,-,table#mergecells-right,table#mergecells-down,table#mergecells,table#splitcell-horizontal,table#splitcell-vertical,-,table#selectrow,table#selectcolumn,-,table#ensureparagraph,-,table#modifytableandcell,table#removetable,-,undo#undo,undo#redo,-,table#exitTableEditing,-]">
                                </tableEditOptions>
                            </cui>
                        </uiSettings>
                    </text>
                    <enable
                        jcr:primaryType="nt:unstructured"
                        jcr:title="Enable"
                        sling:resourceType="granite/ui/components/coral/foundation/container"
                        margin="{Boolean}true">
                        <items jcr:primaryType="nt:unstructured">
                            <enableMessage
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/form/switch"
                                fieldLabel="Enable Message"
                                name="./enableMessage"
                                uncheckedValue="false"
                                value="true" />
                            <daysHidden
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/form/numberfield"
                                fieldDescription="Number of days the current message will stay hidden after it is closed."
                                fieldLabel="Number of Days Hidden"
                                name="./daysHidden"
                                min="0"
                                max="365"
                                value="3" />
                        </items>
                    </enable>
                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

Update the message template for the toggle switch enableMessage property.

  1. Add data-sly-test="${properties.enableMessage == 'true'}" to the outer div.
  2. Update the placeholder template call so the component will be rendered in edit mode when the toggle switch has it disabled.

Here is how the completed message.html template should look after all of these updates:

message/message.html
<div data-sly-test="${properties.enableMessage == 'true'}"
     class="cmp-message ${classCollapse}"
     data-cmp-is="message"
     data-cmp-id="message${resource.path.hashCode}"
     data-days-hidden="${properties.daysHidden}">
     <div data-cmp-hook-message="text">${properties.text @ context='html'}</div>
     <button>close</button>
</div>

<sly data-sly-use.templates="/apps/core/wcm/components/commons/v1/templates.html"
     data-sly-call="${templates.placeholder @ isEmpty = !properties.text || properties.enableMessage == 'false',
     emptyTextAppend = properties.enableMessage == 'false' ? 'Disabled' : ''}"></sly>

Frontend UI

At this point the close button doesn’t do anything. We need to add some CSS and JavaScript to give our message component the close and reappear functionality.

To hide the the message when the button is clicked, we will use a CSS class named collapse. However, when the author is editing the component, this class cannot be applied. Use the wcmmode.edit variable to control removing or adding the class. e.g.,

<sly data-sly-test.classCollapse="${wcmmode.edit || wcmmode.preview ? '' : 'collapse'}" />

<div class="cmp-message ${classCollapse}" data-cmp-is="message">...

Note that we’re preventing the collapse class from being rendered by testing for either edit or preview modes with the || operator.

We will also need to expose some component resource data and additional dialog content from the server for the message component JavaScript to use. This will be handled by data attributes in the template as follows.

<sly data-sly-test.classCollapse="${wcmmode.edit || wcmmode.preview ? '' : 'collapse'}" />

<div class="cmp-message ${classCollapse}"
     data-cmp-is="message"
     data-cmp-id="message${resource.path.hashCode}"
     data-days-hidden="${properties.daysHidden}">
     <div data-cmp-hook-message="text">${properties.text @ context='html'}</div>
     <button>close</button>
</div>
...

CSS

Component CSS and JavaScript is located in the ui.frontend folder of the project. Under /src/main/webpack/components, create a Sass file named _message.scss. e.g.,

_message.scss
.cmp-message {

    transition: all .3s ease;

    &.collapse {
        height: 0;
        visibility: collapse;
    }

}

JavaScript

Under /src/main/webpack/components, create a JavaScript file named _message.js. You could also copy and modify _helloworld.js. e.g.,

_message.js
import { getCookie, setCookie } from './modules/cookie';

// Example of how a component should be initialized via JavaScript

(function() {
    "use strict";

    // Best practice:
    // For a good separation of concerns, don't rely on the DOM structure or CSS selectors,
    // but use dedicated data attributes to identify all elements that the script needs to
    // interact with.
    var selectors = {
        self:      '[data-cmp-is="message"]',
        text:      '[data-cmp-hook-message="text"]'
    };

    function Message(config) {

        const COLLAPSE_CLASS = 'collapse';

        config.element.text = config.element.querySelector(selectors.text);
        config.element.close = config.element.querySelector('button');

        const COOKIE = config.element.text.textContent.trim();
        const COOKIE_NAME = config.element.getAttribute('data-cmp-id');
        const CURRENT_COOKIE = getCookie(COOKIE_NAME);

        function close() {
            const days = parseInt(config.element.getAttribute('data-days-hidden')) || 3;
            setCookie(COOKIE_NAME, COOKIE, days);
            config.element.classList.add(COLLAPSE_CLASS);
        }

        function init(config) {

            // Best practice:
            // To prevents multiple initialization, remove the main data attribute that
            // identified the component.
            config.element.removeAttribute("data-cmp-is");

            if (console && console.log) {
                console.log(
                    "Message component is loaded"
                );
            }

            if (!CURRENT_COOKIE || CURRENT_COOKIE !== COOKIE) {
                config.element.classList.remove(COLLAPSE_CLASS);
            }

            config.element.close.addEventListener("click", close);
        }

        if (config && config.element) {
            init(config);
        }
    }

    // Best practice:
    // Use a method like this mutation obeserver to also properly initialize the component
    // when an author drops it onto the page or modified it with the dialog.
    function onDocumentReady() {
        var elements = document.querySelectorAll(selectors.self);
        for (var i = 0; i < elements.length; i++) {
            new Message({ element: elements[i] });
        }

        var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
        var body             = document.querySelector("body");
        var observer         = new MutationObserver(function(mutations) {
            mutations.forEach(function(mutation) {
                // needed for IE
                var nodesArray = [].slice.call(mutation.addedNodes);
                if (nodesArray.length > 0) {
                    nodesArray.forEach(function(addedNode) {
                        if (addedNode.querySelectorAll) {
                            var elementsArray = [].slice.call(addedNode.querySelectorAll(selectors.self));
                            elementsArray.forEach(function(element) {
                                new Message({ element: element });
                            });
                        }
                    });
                }
            });
        });

        observer.observe(body, {
            subtree: true,
            childList: true,
            characterData: true
        });
    }

    if (document.readyState !== "loading") {
        onDocumentReady();
    } else {
        document.addEventListener("DOMContentLoaded", onDocumentReady);
    }

}());

Notice the cookie module import at the very top of our _message.js file. Let’s add that module as follows. Create a modules folder under components. e.g.,

cd ui.frontend

mkdir -p src/main/webpack/components/modules

Then create the cookie module.

modules/cookie.js
function getCookie(name) {
    var val = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
    return val ? val[2] : null;
}

function setCookie(name, value, days) {
    var date = new Date;
    date.setTime(date.getTime() + 24*60*60*1000*days);
    document.cookie = name + "=" + value + ";path=/;expires=" + date.toGMTString();
}

export {
    getCookie,
    setCookie
};

Frontend Build

As of this writing, the frontend build entry-point did not have a components import path.

In order to have the frontend build include the component JavaScript files, update the import path in the ui.frontend/src/main/webpack/site/main.ts entry-point file. e.g.,

main.ts
// builds component files into site.js
import "../components/*.js";

Now we’re ready to build the client-side libraries using the ui.frontend build process.

cd ui.frontend

npm run dev

When this process has completed, the projects clientlib-site/js/site.js file will be built using the import paths from ui.frontend/src/main/webpack/site/main.ts. Since we ran the dev build, source maps are included.

The ui.frontend build process README contains all the details.

Tip for updating the static HTML template for use with the Webpack dev server: use wget as shown in the example below.

cd ui.frontend

wget --user=admin --password=admin -O src/main/webpack/static/index.html http://localhost:4502/content/myproject/us/en.html\?wcmmode\=disabled

npm run start

Follow the instructions in the aem-project-archetype for building and deploying the project files to AEM.

I like using the AEM Repo tool for incremental file updates since Maven builds take some time. For example, to push updated client libraries into the AEM server, use repo put:

cd ui.apps/src/main/content/jcr_root

repo put apps/myproject/clientlibs/clientlib-site

Follow Up

This tutorial get’s you started and the styling is very basic. Customize the CSS of the component as needed in the _message.scss Sass file. You could even absolute position the message like a modal, adding a max-width, border and/or drop shadow.

message component demo


Source Code

Part 3 of 3 in the AEM Component Dev series.

Part 1 | Folding Panel Component

comments powered by Disqus