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