AEM 65 - Setup a Review Env for Preview before Publishing to Live

Goal


Business users with no access to AEM may want to review the page (content and layout) before authors activate the page to Publish (and make it Live). A typical review process may contains the following steps...

1) Replicate the page content to a Review environment

2) Preview the page in Review environment (pre production)

3) Approve the page (or provide feedback)

4) Activate the page to Publish

The following process provides a solution for the first two steps...


Demo | Package Install | Github


Publish to Review




Review in Progress - Card View



Review in Progress - List View



Review in Progress - Column View



Solution


1) Setup a publish environment behind the organization firewall and label it Review / Preview environment. Content is first pushed to this environment probably in a workflow process before replicating to Publish. Locally, for demo, its running on 4504

                         Author - http://localhost:4502
                         Publish - http://localhost:4503
                         Review - http://localhost:4504

2) Create a replication agent Publish to Review Agent(review_agent) to push content from Author to Review (Package Install has a sample replication agent /etc/replication/agents.author/review_agent)






3) Set the Transport URI, Transport Username and Password of the Review env in agent (adding admin user credentials is not recommended).

<?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="cq:Page">
    <jcr:content
        cq:lastModified="{Date}2019-08-09T09:57:33.965-05:00"
        cq:lastModifiedBy="admin"
        cq:template="/libs/cq/replication/templates/agent"
        jcr:lastModified="{Date}2019-08-09T09:57:33.957-05:00"
        jcr:lastModifiedBy="admin"
        jcr:primaryType="nt:unstructured"
        jcr:title="Publish to Review Agent"
        sling:resourceType="cq/replication/components/agent"
        enabled="true"
        transportPassword="\{40033b099a3b0d7e8f360c8623e446e1fd2171f5c621d72a3d30ba07cdc00793}"
        transportUri="http://localhost:4504/bin/receive?sling:authRequestLogin=1"
        transportUser="admin"
        userId="admin"/>
</jcr:root>


4) Test, make sure the agent is enabled and reachable



5) To simplify the process add a button in action bar Publish to Review (like Quick Publish) to push page (and references) to Review with the following code, in node /apps/eaem-publish-page-to-review-env/content/publish-toreview-toolbar-ext

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:granite="http://www.adobe.com/jcr/granite/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
    granite:rel="cq-damadmin-admin-actions-publish-to-review-activator"
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/collection/action"
    activeSelectionCount="multiple"
    icon="dimension"
    target=".cq-damadmin-admin-childpages"
    text="Publish to Review"
    variant="actionBar">
    <data
        jcr:primaryType="nt:unstructured"
        text="Page published to review environment"/>
</jcr:root>




6) In CRXDE Lite (http://localhost:4502/crx/de), create folder /apps/eaem-publish-page-to-review-env

7) Create node /apps/eaem-publish-page-to-review-env/clientlib of type cq:ClientLibraryFolder, add String property categories with value cq.common.wcm, String[] property dependencies with value lodash

8) Create file (nt:file) /apps/eaem-publish-page-to-review-env/clientlib/js.txt, add

                        add-publish-to-review-action.js

9) Create file (nt:file) /apps/eaem-publish-page-to-review-env/clientlib/add-publish-to-review-action.js, add the following code

(function ($, $document) {
    var BUTTON_URL = "/apps/eaem-publish-page-to-review-env/content/publish-toreview-toolbar-ext.html",
        QUICK_PUBLISH_ACTIVATOR = "cq-siteadmin-admin-actions-quickpublish-activator",
        REVIEW_STATUS_URL = "/bin/eaem/sites/review/status?parentPath=",
        PUBLISH_TO_REVIEW = "/bin/eaem/sites/review/publish?pagePaths=",
        F_CONTENT_LOADED = "foundation-contentloaded",
        F_MODE_CHANGE = "foundation-mode-change",
        F_SEL_CHANGE = "foundation-selections-change",
        F_COL_ITEM_ID = "foundationCollectionItemId",
        F_COL_ACTION = "foundationCollectionAction",
        FOUNDATION_COLLECTION_ID = "foundation-collection-id",
        LAYOUT_COL_VIEW  = "column",
        LAYOUT_LIST_VIEW = "list",
        LAYOUT_CARD_VIEW = "card",
        COLUMN_VIEW = "coral-columnview",
        EVENT_COLUMNVIEW_CHANGE = "coral-columnview:change",
        FOUNDATION_COLLECTION_ITEM = ".foundation-collection-item",
        FOUNDATION_COLLECTION_ITEM_ID = "foundation-collection-item-id",
        CORAL_COLUMNVIEW_PREVIEW = "coral-columnview-preview",
        CORAL_COLUMNVIEW_PREVIEW_ASSET = "coral-columnview-preview-asset",
        EAEM_BANNER_CLASS = "eaem-banner",
        EAEM_BANNER = ".eaem-banner",
        FOUNDATION_COLLECTION_ITEM_TITLE = ".foundation-collection-item-title",
        SITE_ADMIN_CHILD_PAGES = ".cq-siteadmin-admin-childpages",
        NEW_BANNER = "New",
        colViewListenerAdded = false,
        reviewButtonAdded = false;

    $document.on(F_CONTENT_LOADED, removeNewBanner);

    $document.on(F_CONTENT_LOADED, checkReviewStatus);

    $document.on(F_SEL_CHANGE, function () {
        if(reviewButtonAdded){
            return;
        }

        reviewButtonAdded = true;

        colViewListenerAdded = false;

        checkReviewStatus();

        $.ajax(BUTTON_URL).done(addButton);
    });

    function removeNewBanner(){
        var $container = $(SITE_ADMIN_CHILD_PAGES), $label,
            $items = $container.find(FOUNDATION_COLLECTION_ITEM);

        _.each($items, function(item){
            $label = $(item).find("coral-card-info coral-tag");

            if(_.isEmpty($label) || $label.find("coral-tag-label").html().trim() != NEW_BANNER){
                return;
            }

            $label.remove();
        });
    }

    function checkReviewStatus(){
        var parentPath = $(SITE_ADMIN_CHILD_PAGES).data(FOUNDATION_COLLECTION_ID);

        if(_.isEmpty(parentPath)){
            return;
        }

        $.ajax(REVIEW_STATUS_URL + parentPath).done(showBanners);
    }

    function showBanners(pathsObj){
        if(isColumnView()){
            handleColumnView();
        }

        if(_.isEmpty(pathsObj)){
            return;
        }

        if(isCardView()){
            addCardViewBanner(pathsObj);
        }else if(isListView()){
            addListViewBanner(pathsObj)
        }
    }

    function handleColumnView(){
        var $columnView = $(COLUMN_VIEW);

        if(colViewListenerAdded){
            return;
        }

        colViewListenerAdded = true;

        $columnView.on(EVENT_COLUMNVIEW_CHANGE, handleColumnItemSelection);
    }

    function handleColumnItemSelection(event){
        var detail = event.originalEvent.detail,
            $page = $(detail.selection[0]),
            pagePath = $page.data(FOUNDATION_COLLECTION_ITEM_ID);

        if(_.isEmpty(pagePath)){
            return;
        }

        $.ajax(REVIEW_STATUS_URL + pagePath).done(addColumnViewBanner);
    }

    function addColumnViewBanner(pageObj){
        getUIWidget(CORAL_COLUMNVIEW_PREVIEW).then(handler);

        function handler($colPreview){
            var $pagePreview = $colPreview.find(CORAL_COLUMNVIEW_PREVIEW_ASSET),
                pagePath = $colPreview.data("foundation-layout-columnview-columnid"),
                state = pageObj[pagePath];

            if(_.isEmpty(state)){
                return;
            }

            $pagePreview.find(EAEM_BANNER).remove();

            $pagePreview.prepend(getBannerColumnView(state));
        }
    }

    function getBannerColumnView(state){
        var ct = getColorText(state);

        if(!ct.color){
            return;
        }

        return "<coral-tag style='background-color: " + ct.color + ";z-index: 9999; width: 100%' class='" + EAEM_BANNER_CLASS + "'>" +
                    "<i class='coral-Icon coral-Icon--bell coral-Icon--sizeXS' style='margin-right: 10px'></i>" + ct.text +
                "</coral-tag>";
    }

    function getBannerHtml(state){
        var ct = getColorText(state);

        if(!ct.color){
            return;
        }

        return "<coral-alert style='background-color:" + ct.color + "' class='" + EAEM_BANNER_CLASS + "'>" +
                    "<coral-alert-content>" + ct.text + "</coral-alert-content>" +
               "</coral-alert>";
    }

    function getColorText(state){
        var color, text;

        if(_.isEmpty(state)){
            return
        }

        if(state == "IN_PROGRESS"){
            color = "#ff7f7f";
            text = "Review in progress"
        }

        return{
            color: color,
            text: text
        }
    }

    function addListViewBanner(pathsObj){
        var $container = $(SITE_ADMIN_CHILD_PAGES), $item, ct;

        _.each(pathsObj, function(state, pagePath){
            $item = $container.find("[data-" + FOUNDATION_COLLECTION_ITEM_ID + "='" + pagePath + "']");

            if(!_.isEmpty($item.find(EAEM_BANNER))){
                return;
            }

            ct = getColorText(state);

            if(!ct.color){
                return;
            }

            $item.find("td").css("background-color" , ct.color).addClass(EAEM_BANNER_CLASS);

            $item.find(FOUNDATION_COLLECTION_ITEM_TITLE).prepend(getListViewBannerHtml());
        });
    }

    function getListViewBannerHtml(){
        return "<i class='coral-Icon coral-Icon--bell coral-Icon--sizeXS' style='margin-right: 10px'></i>";
    }

    function addCardViewBanner(pathsObj){
        var $container = $(SITE_ADMIN_CHILD_PAGES), $item;

        _.each(pathsObj, function(state, pagePath){
            $item = $container.find("[data-" + FOUNDATION_COLLECTION_ITEM_ID + "='" + pagePath + "']");

            if(_.isEmpty($item)){
                return;
            }

            if(!_.isEmpty($item.find(EAEM_BANNER))){
                return;
            }

            $item.find("coral-card-info").append(getBannerHtml(state));
        });
    }

    function isColumnView(){
        return ( getAssetsConsoleLayout() === LAYOUT_COL_VIEW );
    }

    function isListView(){
        return ( getAssetsConsoleLayout() === LAYOUT_LIST_VIEW );
    }

    function isCardView(){
        return (getAssetsConsoleLayout() === LAYOUT_CARD_VIEW);
    }

    function getAssetsConsoleLayout(){
        var $childPage = $(SITE_ADMIN_CHILD_PAGES),
            foundationLayout = $childPage.data("foundation-layout");

        if(_.isEmpty(foundationLayout)){
            return "";
        }

        return foundationLayout.layoutId;
    }

    function getUIWidget(selector){
        if(_.isEmpty(selector)){
            return;
        }

        var deferred = $.Deferred();

        var INTERVAL = setInterval(function(){
            var $widget = $(selector);

            if(_.isEmpty($widget)){
                return;
            }

            clearInterval(INTERVAL);

            deferred.resolve($widget);
        }, 250);

        return deferred.promise();
    }

    function startsWith(val, start){
        return val && start && (val.indexOf(start) === 0);
    }

    function addButton(html) {
        var $eActivator = $("." + QUICK_PUBLISH_ACTIVATOR);

        if ($eActivator.length == 0) {
            return;
        }

        var $convert = $(html).css("margin-left", "20px").insertBefore($eActivator);

        $convert.click(postPublishToReviewRequest);
    }

    function postPublishToReviewRequest(){
        var actionConfig = ($(this)).data(F_COL_ACTION);

        var $items = $(".foundation-selections-item"),
            pagePaths = [];

        $items.each(function () {
            pagePaths.push($(this).data(F_COL_ITEM_ID));
        });

        $.ajax(PUBLISH_TO_REVIEW + pagePaths.join(",")).done(function(){
            showAlert(actionConfig.data.text, "Publish");
        });
    }

    function showAlert(message, title, callback){
        var fui = $(window).adaptTo("foundation-ui"),
            options = [{
                id: "ok",
                text: "OK",
                primary: true
            }];

        message = message || "Unknown Error";
        title = title || "Error";

        fui.prompt(title, message, "default", options, callback);
    }

}(jQuery, jQuery(document)));


10) Create a servlet apps.experienceaem.sites.PublishToReviewServlet to collect references in page and publish to Review using replication agent  review_agent


package apps.experienceaem.sites;

import com.day.cq.commons.jcr.JcrConstants;
import com.day.cq.replication.*;
import com.day.cq.wcm.api.reference.ReferenceProvider;
import org.apache.commons.lang3.StringUtils;
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.servlets.SlingAllMethodsServlet;
import org.apache.sling.servlets.post.JSONResponse;
import org.json.JSONObject;
import org.osgi.service.component.annotations.Component;
import com.day.cq.wcm.api.reference.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.security.AccessControlManager;
import javax.jcr.security.Privilege;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

@Component(
        name = "Experience AEM Publish to Review Servlet",
        immediate = true,
        service = Servlet.class,
        property = {
                "sling.servlet.methods=GET",
                "sling.servlet.paths=/bin/eaem/sites/review/status",
                "sling.servlet.paths=/bin/eaem/sites/review/publish"
        }
)
public class PublishToReviewServlet extends SlingAllMethodsServlet {
    private static final Logger log = LoggerFactory.getLogger(PublishToReviewServlet.class);

    public static final String PUBLISH_TO_REVIEW_URL = "/bin/eaem/sites/review/publish";
    public static final String STATUS_URL = "/bin/eaem/sites/review/status";

    private static final String REVIEW_AGENT = "review_agent";
    private static final String REVIEW_STATUS = "reviewStatus";
    private static final String REVIEW_STATUS_IN_PROGRESS = "IN_PROGRESS";

    @org.osgi.service.component.annotations.Reference
    Replicator replicator;

    @org.osgi.service.component.annotations.Reference(
            service = ReferenceProvider.class,
            cardinality = ReferenceCardinality.MULTIPLE,
            policy = ReferencePolicy.DYNAMIC)
    private final List<ReferenceProvider> referenceProviders = new CopyOnWriteArrayList<ReferenceProvider>();

    protected void bindReferenceProviders(ReferenceProvider referenceProvider) {
        referenceProviders.add(referenceProvider);
    }

    protected void unbindReferenceProviders(ReferenceProvider referenceProvider) {
        referenceProviders.remove(referenceProvider);
    }

    @Override
    protected final void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws
            ServletException, IOException {
        try {
            addJSONHeaders(response);

            if(PUBLISH_TO_REVIEW_URL.equals(request.getRequestPathInfo().getResourcePath())){
                handlePublish(request, response);
            }else{
                handleStatus(request, response);
            }
        } catch (Exception e) {
            log.error("Error processing publish to review...");
            response.setStatus(SlingHttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }

    private void handleStatus(SlingHttpServletRequest request, SlingHttpServletResponse response) throws Exception {
        JSONObject jsonObject = new JSONObject();

        String parentPath = request.getParameter("parentPath");

        if(StringUtils.isEmpty(parentPath)){
            jsonObject.put("error", "No parent path provided");
            jsonObject.write(response.getWriter());
            return;
        }

        ResourceResolver resolver = request.getResourceResolver();
        Session session = resolver.adaptTo(Session.class);

        if ((session != null) && session.isLive() && !session.nodeExists(parentPath)) {
            log.debug("No such node {} ", parentPath);
            return;
        }

        jsonObject = getReviewInProgressPages(resolver.getResource(parentPath));

        jsonObject.write(response.getWriter());
    }

    private JSONObject getReviewInProgressPages(Resource resource) throws Exception{
        final JSONObject pagePaths = new JSONObject();

        final Iterator<Resource> childResItr = resource.listChildren();
        Resource childRes, jcrContent;
        Node jcrContentNode;

        while (childResItr.hasNext()) {
            childRes = childResItr.next();

            if(childRes.getName().equals("jcr:content")){
                jcrContent = childRes;
            }else{
                jcrContent = childRes.getChild("jcr:content");
            }

            if(jcrContent == null){
                continue;
            }

            jcrContentNode = jcrContent.adaptTo(Node.class);

            if (!jcrContentNode.hasProperty(REVIEW_STATUS)
                    || !jcrContentNode.getProperty(REVIEW_STATUS).getString().equals(REVIEW_STATUS_IN_PROGRESS)) {
                continue;
            }

            if(childRes.getName().equals("jcr:content")){
                pagePaths.put(childRes.getParent().getPath(), REVIEW_STATUS_IN_PROGRESS);
            }else{
                pagePaths.put(childRes.getPath(), REVIEW_STATUS_IN_PROGRESS);
            }
        }

        return pagePaths;
    }


    private void handlePublish(SlingHttpServletRequest request, SlingHttpServletResponse response) throws Exception {
        JSONObject jsonResponse = new JSONObject();
        List<String> publishPaths = new ArrayList<String>();

        ResourceResolver resolver = request.getResourceResolver();
        Session session = resolver.adaptTo(Session.class);
        String pagePaths = request.getParameter("pagePaths");

        for(String pagePath : pagePaths.split(",")){
            Resource page = resolver.getResource(pagePath);
            Resource jcrContent = resolver.getResource(page.getPath() + "/" + JcrConstants.JCR_CONTENT);

            Set<Reference> allReferences = new TreeSet<Reference>(new Comparator<Reference>() {
                public int compare(Reference o1, Reference o2) {
                    return o1.getResource().getPath().compareTo(o2.getResource().getPath());
                }
            });

            for (ReferenceProvider referenceProvider : referenceProviders) {
                allReferences.addAll(referenceProvider.findReferences(jcrContent));
            }

            for (Reference reference : allReferences) {
                Resource resource = reference.getResource();

                if (resource == null) {
                    continue;
                }

                boolean canReplicate = canReplicate(resource.getPath(), session);

                if(!canReplicate){
                    log.warn("Skipping, No replicate permission on - " + resource.getPath());
                    continue;
                }

                if(shouldReplicate(reference)){
                    publishPaths.add(resource.getPath());
                }
            }

            jcrContent.adaptTo(Node.class).setProperty(REVIEW_STATUS, REVIEW_STATUS_IN_PROGRESS);

            publishPaths.add(pagePath);
        }

        session.save();

        doReplicate(publishPaths, session);

        jsonResponse.put("success", "true");

        response.getWriter().write(jsonResponse.toString());
    }

    private static boolean canReplicate(String path, Session session) {
        try {
            AccessControlManager acMgr = session.getAccessControlManager();

            return acMgr.hasPrivileges(path, new Privilege[]{
                    acMgr.privilegeFromName(Replicator.REPLICATE_PRIVILEGE)
            });
        } catch (RepositoryException e) {
            return false;
        }
    }

    private boolean shouldReplicate(Reference reference){
        Resource resource = reference.getResource();
        ReplicationStatus replStatus = resource.adaptTo(ReplicationStatus.class);

        if (replStatus == null) {
            return true;
        }

        boolean doReplicate = false, published = false, outdated = false;
        long lastPublished = 0;

        published = replStatus.isActivated();

        if (published) {
            lastPublished = replStatus.getLastPublished().getTimeInMillis();
            outdated = lastPublished < reference.getLastModified();
        }

        if (!published || outdated) {
            doReplicate = true;
        }

        return doReplicate;
    }

    private void doReplicate(List<String> paths, Session session) throws Exception{
        ReplicationOptions opts = new ReplicationOptions();

        opts.setFilter(new AgentFilter() {
            public boolean isIncluded(com.day.cq.replication.Agent agent) {
                return agent.getId().equalsIgnoreCase(REVIEW_AGENT);
            }
        });

        for(String path : paths){
            replicator.replicate(session, ReplicationActionType.ACTIVATE, path, opts);
        }
    }

    public static void addJSONHeaders(SlingHttpServletResponse response){
        response.setContentType(JSONResponse.RESPONSE_CONTENT_TYPE);
        response.setHeader("Cache-Control", "nocache");
        response.setCharacterEncoding("utf-8");
    }
}

11) The necessary packages with components and templates are pre installed in Review (Package Install has sample template /apps/eaem-basic-htl-page-template)

12) Page in Review environment


1 comment:

  1. Looks like this works for content pages.
    How about Experience Fragment, any suggestions.

    ReplyDelete