
Background
A customer of ours had a need to change properties to the same value for a bulk of documents at once. So we created an action in the document library where you could select a bunch of documents of the same content type, and then choose “edit multiple” from the actions menu. You are then presented with the gui shown to the left. Tick the checkbox next to the field to enable it for bulk edit. When hitting the save button all the documents selected are updated with the values at once.
Implementation
First we need to indicate the document type in the document library listing, otherwise it will be hard for the user to know which of the documents that share the same metadata, and therefore could be subject to a bulk change. This is done by registering a new renderer in javascript:
(function () {
if (Alfresco.DocumentList) {
YAHOO.Bubbling.fire("registerRenderer", {
propertyName: "type",
renderer: function (record, label) {
var key = 'type.' + record.jsNode.type.replace(":", "_");
return '<span class="item">' + Alfresco.util.message('label.nodeType') +
': ' + Alfresco.util.message(key) + '</span>';
}
});
}
})();
Include the javascript file in a DocLibCustom directive:
<config evaluator="string-compare" condition="DocLibCustom">
<dependencies>
<js src="components/documentlibrary/documentlist-display-type.js" />
</dependencies>
</config>
The result will be something like this (with our customisation highlighted):

Configure our new bulk action
<config evaluator="string-compare" condition="DocumentLibrary">
<multi-select>
<action type="action-link" id="onActionEditMultipleDocumentMetadata" asset="document" permission="Write" label="actions.editMultipleDocumentMetadata" />
</multi-select>
</config>
window.RPLP = window.RPLP || {};
window.RPLP.globalNodeRefs = [];
(function () {
/**
* YUI Library aliases
*/
var Dom = YAHOO.util.Dom,
Event = YAHOO.util.Event;
/**
* Alfresco Slingshot aliases
*/
var $html = Alfresco.util.encodeHTML,
$combine = Alfresco.util.combinePaths,
$siteURL = Alfresco.util.siteURL,
$isValueSet = Alfresco.util.isValueSet;
YAHOO.Bubbling.fire("registerAction", {
actionName: "onActionEditMultipleDocumentMetadata",
fn: function rplp_onActionEditMultipleDocumentMetadata(record) {
var type = record[0].jsNode.type;
// Check that all the nodes are of same type
var allSame = true;
var nodeRefs = [];
for (var i = 0, ii = record.length; i < ii; i++) {
jsNode = record[i].jsNode;
if (type !== jsNode.type) {
allSame = false;
break;
}
nodeRefs.push(record[i].nodeRef);
}
window.RPLP.globalNodeRefs = nodeRefs;
if (allSame === false) {
Alfresco.util.PopupManager.displayMessage({
text: this.msg("rplp.actions.editMultipleDocumentMetadata.multipleTypes"),
displayTime: 2.0
});
} else { // All are of same type
// Spawn form
var scope = this,
nodeRef = record[0].nodeRef,
jsNode = record[0].jsNode;
// Intercept before dialog show
var doBeforeDialogShow = function dlA_onActionDetails_doBeforeDialogShow(p_form, p_dialog) {
// Dialog title
var fileSpan = '<span class="light">' + this.msg("rplp.actions.editMultipleDocumentMetadata.dialogTitle") + '</span>';
Alfresco.util.populateHTML(
[p_dialog.id + "-dialogTitle", scope.msg("edit-details.title", fileSpan)]
);
// Edit metadata link button
this.widgets.editMetadata = Alfresco.util.createYUIButton(p_dialog, "editMetadata", null, {
type: "link",
label: scope.msg("edit-details.label.edit-metadata"),
href: $siteURL("edit-metadata?nodeRef=" + nodeRef)
});
};
var templateUrl = YAHOO.lang.substitute(Alfresco.constants.URL_SERVICECONTEXT + "components/form?itemKind={itemKind}&itemId={itemId}&destination={destination}&mode={mode}&submitType={submitType}&formId={formId}&showCancelButton=true", {
itemKind: "node",
itemId: nodeRef,
mode: "edit",
submitType: "json",
formId: "multiple-edit-metadata"
});
// Using Forms Service, so always create new instance
var editDetails = new Alfresco.module.SimpleDialog(this.id + "-editDetails-" + Alfresco.util.generateDomId());
editDetails.setOptions({
width: "auto",
templateUrl: templateUrl,
actionUrl: null,
destroyOnHide: true,
doBeforeDialogShow: {
fn: doBeforeDialogShow,
scope: this
},
onSuccess: {
fn: function dlA_onActionDetails_success(response) {
// Reload the node's metadata
var webscriptPath = "components/documentlibrary/data";
if ($isValueSet(this.options.siteId)) {
webscriptPath += "/site/" + encodeURIComponent(this.options.siteId)
}
Alfresco.util.Ajax.request({
url: $combine(Alfresco.constants.URL_SERVICECONTEXT, webscriptPath, "/node/", jsNode.nodeRef.uri) + "?view=" + this.actionsView,
successCallback: {
fn: function dlA_onActionDetails_refreshSuccess(response) {
// Display success message
Alfresco.util.PopupManager.displayMessage({
text: this.msg("message.details.success")
});
// Refresh the document list...
YAHOO.Bubbling.fire("metadataRefresh");
},
scope: this
},
failureCallback: {
fn: function dlA_onActionDetails_refreshFailure(response) {
Alfresco.util.PopupManager.displayMessage({
text: this.msg("message.details.failure")
});
},
scope: this
}
});
},
scope: this
},
onFailure: {
fn: function dLA_onActionDetails_failure(response) {
var failureMsg = this.msg("message.details.failure");
if (response.json && response.json.message.indexOf("Failed to persist field 'prop_cm_name'") !== -1) {
failureMsg = this.msg("message.details.failure.name");
}
Alfresco.util.PopupManager.displayMessage({
text: failureMsg
});
},
scope: this
}
}).show();
}
}
});
})();
Our new action first declares a global javascript variable to hold an array of all nodeRefs (window.RPLP.globalNodeRefs = [];) that should be bulk edited. I found no other convenient way to pass them on to the ftl-template that later on will render the form and place those in a hidden form field. When the action gets fired it first checks that all nodes requested for bulk edit are of the same type before moving on to call the forms engine to render the form. When calling the forms engine we will do this with a new formId, “multiple-edit-metadata”. This way we can configure our form with xml the regular way.
Don’t forget to configure the message keys in the message resource bundle as well.
Configure multiple-edit-metadata forms for types
For every content-type you want to bulk-edit, create an xml config with “multiple-edit-metadata” as formId:
<config evaluator="string-compare" condition="DocumentLibrary">
<!-- Form used when editing multiple nodes of same type at the same time. -->
<form id="multiple-edit-metadata">
<edit-form template="/se/redpill/alfresco/share/global/components/form/multiple-node-simple-metadata.ftl" />
<field-visibility>
<show id="rplp:externalReference" />
<hide id="rplp:documentStatusType" />
<hide id="rplp:reviewStatusType" />
<show id="rplp:securityClassificationType" />
<show id="rplp:applicationRecipient" />
<show id="rplp:applicationType" />
</field-visibility>
<appearance>
<field id="rplp:externalReference">
<control template="/se/redpill/alfresco/share/global/components/form/controls/multi-edit/textfield-cb.ftl">
<control-param name="styleClass">autoWidth</control-param>
</control>
</field>
<field id="rplp:documentStatusType">
<control template="/se/redpill/alfresco/share/global/components/form/controls/multi-edit/selectone-cb.ftl">
<control-param name="addEmptyOption">true</control-param>
<control-param name="styleClass">selectWidth</control-param>
</control>
</field>
<field id="rplp:reviewStatusType">
<control template="/se/redpill/alfresco/share/global/components/form/controls/multi-edit/selectone-cb.ftl">
<control-param name="addEmptyOption">true</control-param>
<control-param name="styleClass">selectWidth</control-param>
</control>
</field>
<field id="rplp:securityClassificationType">
<control template="/se/redpill/alfresco/share/global/components/form/controls/multi-edit/selectone-cb.ftl">
<control-param name="addEmptyOption">true</control-param>
<control-param name="styleClass">selectWidth</control-param>
</control>
</field>
<field id="rplp:applicationRecipient">
<control template="/se/redpill/alfresco/share/global/components/form/controls/multi-edit/textfield-cb.ftl">
<control-param name="styleClass">autoWidth</control-param>
</control>
</field>
<field id="rplp:applicationType">
<control template="/se/redpill/alfresco/share/global/components/form/controls/multi-edit/selectone-cb.ftl">
<control-param name="addEmptyOption">true</control-param>
<control-param name="styleClass">selectWidth</control-param>
</control>
</field>
</appearance>
</form>
</config>
We will use a custom freemarker template for the edit-form to be able to pass the array of nodes on to the backend service. Notice the javascript at the end of the template poplulating the hidden input value.
<#if formUI == "true">
<@formLib.renderFormsRuntime formId=formId />
</#if>
<div id="${args.htmlid}-dialog">
<div id="${args.htmlid}-dialogTitle" class="hd"></div>
<div class="bd">
<div id="${formId}-container" class="form-container">
<#if form.showCaption?exists && form.showCaption>
<div id="${formId}-caption" class="caption"><span class="mandatory-indicator">*</span>${msg("form.required.fields")}</div>
</#if>
<form id="${formId}" method="${form.method}" accept-charset="utf-8" enctype="${form.enctype}" action="${form.submissionUrl}">
<input type="hidden" id="muliple-edit-nodeRefs" />
<div id="${formId}-fields" class="form-fields">
<#list form.structure as item>
<#if item.kind == "set">
<@formLib.renderSet set=item />
<#else>
<@formLib.renderField field=form.fields[item.id] />
</#if>
</#list>
</div>
<div class="bdft">
<input id="${formId}-submit" type="submit" value="${msg("form.button.submit.label")}" />
<input id="${formId}-cancel" type="button" value="${msg("form.button.cancel.label")}" />
</div>
</form>
</div>
</div>
</div>
<script type="text/javascript">
YAHOO.util.Event.onAvailable('muliple-edit-nodeRefs', function(){
// Set which nodeRefs are involved in the multiple update.
YAHOO.util.Dom.get('muliple-edit-nodeRefs').value = window.RPLP.globalNodeRefs.join();
});
</script>
One of the drawbacks with this solution is that every form component have been duplicated. This is done because of the checkbox that sits next to every input type. If you want to edit the field you need to first tick the box. It would problaby be possible to use javascript instead to post-process the DOM-tree and dynamically add a checkbox next to every input. That way there will be no need to add a form component for every input type. However we only had a few input types and were a bit short of time, so I made one whenever needed. Below is the ftl-code for the regular textbox.
<div class="yui-g">
<div class="yui-u first">
<div class="form-field">
<label for="${fieldHtmlId}">${field.label?html}:<#if field.mandatory><span class="mandatory-indicator">${msg("form.required.fields.marker")}</span></#if></label>
<input id="${fieldHtmlId}" name="${field.name}" tabindex="0"
<#if field.control.params.password??>type="password"<#else>type="text"</#if>
<#if field.control.params.styleClass??>class="${field.control.params.styleClass}"</#if>
<#if field.control.params.style??>style="${field.control.params.style}"</#if>
<#if field.value?is_number>value="${field.value?c}"<#else>value="${field.value?html}"</#if>
<#if field.description??>title="${field.description}"</#if>
<#if field.control.params.maxLength??>maxlength="${field.control.params.maxLength}"<#else>maxlength="1024"</#if>
<#if field.control.params.size??>size="${field.control.params.size}"</#if>
disabled="true" />
<@formLib.renderFieldHelp field=field />
</div>
</div>
<div class="yui-u">
<div class="form-field">
<br/>
<input class="formsCheckBox" id="${fieldHtmlId}-entry" type="checkbox" tabindex="0"
onchange='disableSiblingInputField("${fieldHtmlId}");' />
<label for="${fieldHtmlId}-entry" class="checkbox">Redigera</label>
</div>
</div>
</div>
<script type="text/javascript">//<![CDATA[
function disableSiblingInputField(fieldId){
var fieldToDisable = YAHOO.util.Dom.get(fieldId);
if (fieldToDisable.disabled === true){
fieldToDisable.disabled = false;
}else {
fieldToDisable.disabled = true;
}
}
//]]></script>
The backend
The last thing needed to make all the parts work, was to subclass the NodeFormProcessor on the repository side and call the persist method in a loop for all nodes in our array:
<!-- override the form processor to be able to edit multiple nodes at once. -->
<bean id="nodeFormProcessor"
class="se.redpill.alfresco.repo.forms.RplpNodeFormProcessor"
parent="baseFormProcessor">
<property name="filterRegistry" ref="nodeFilterRegistry" />
<property name="matchPattern">
<value>node</value>
</property>
</bean>
package se.redpill.alfresco.repo.forms;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.forms.FormData;
import org.alfresco.repo.forms.FormData.FieldData;
import org.alfresco.repo.forms.processor.node.NodeFormProcessor;
import org.alfresco.service.cmr.repository.NodeRef;
import org.apache.log4j.Logger;
/**
* The node form processor is overridden in order to be able to meet
* the requirement to update properties of muliple nodes at the same
* time. This implementation will try to save every requested node,
* and collect info on failed ones. If there was one failure an exception
* will be thrown after successful persistance of the other ones in order
* to inform the user.
*
* @author erik.billerby@redpill-linpro.com
*
*/
public class RplpNodeFormProcessor extends NodeFormProcessor {
private static final Logger logger = Logger.getLogger(RplpNodeFormProcessor.class);
protected static String MULTIPLE_NODE_REFS_FIELD_NAME = "muliple-edit-nodeRefs";
@Override
protected NodeRef internalPersist(NodeRef item, FormData data) {
List<String> nodeRefs = null;
List<NodeRef> failedNodeRefs = new ArrayList<>();
for (FieldData fieldData : data) {
String fieldName = fieldData.getName();
if (MULTIPLE_NODE_REFS_FIELD_NAME.equals(fieldName)) {
if (logger.isDebugEnabled()){
logger.debug("This is a call to update properties for multiple nodes at once.");
}
String value = (String) fieldData.getValue();
nodeRefs = Arrays.asList(value.split("\\s*,\\s*"));
// we found it, no need to continue the loop
break;
}
}
if (nodeRefs != null) {
for (String nodeRefString : nodeRefs) {
NodeRef nodeRef = new NodeRef(nodeRefString);
try {
super.internalPersist(nodeRef, data);
}catch(Exception e){
failedNodeRefs.add(nodeRef);
}
}
// After saving all nodes possible check if we got any error and throw exception
// to inform user on which nodes we could not update.
if (!failedNodeRefs.isEmpty()){
StringBuilder sb = new StringBuilder();
for (NodeRef failedNode: failedNodeRefs){
String name = (String) nodeService.getProperty(failedNode, ContentModel.PROP_NAME);
sb.append(name).append(",");
}
throw new AlfrescoRuntimeException("rplp.exception.update-multiple-nodes.failedNodes", new String[] { sb.toString() });
}
return item;
} else {
// This is a normal single node call.
return super.internalPersist(item, data);
}
}
}