Friday, December 19, 2008

Writing Webscripts 1 - Unit Testing Web Scripts

There is a big push in the agile community to develop code using a Test Driven Development approach. This could be anything from 'test first' extreme programming to assuring a certain coverage metric is achieved with unit tests that accompany delivered code. Either case, the test driven approach yields better code in a shorter period of time, is more self documented, better factored, and is not as prone to chaos effects when changed. Should web scripts be subject to unit testing? I believe it is important to test at least the data web scripts using a testing harness like xUnit that can be run during builds or in continuous integration. There are examples of this type of test within the Alfresco 3.0 source tree.

This blog entry will focus on creating a project in Eclipse that leverages the Alfresco SDK (currently 3c) to test and deploy web scripts to the Alfresco repository. These unit tests will run with Embedded Alfresco configuration to be as self-contained as possible. Although these tests will not be unit tests in the strictest sense of the word, they will help facilitate more rapid development of code with the appropriate tests within an IDE. These tests can also be run as part of continuous integration.

The sourcecode for this article is available from this site.

Step 1 - Download and Install SDK with Eclipse
Follow the Alfresco SDK wiki page to install the SDK. Essentially, you will execute the following steps:
  1. Download alfresco-sdk-3c
  2. Import into Eclipse
  3. Add dependency libraries to SDK AlfrescoEmbedded
  4. Validate with SDK FirstFoundationClient

Step 2 - Setup the project
In order to run unit tests that test data web scripts properly, we need to set up a project that can lauch Alfresco as an Embedded server, with sufficient configuration files to allow us to test our data web scripts.
Here is a summary of the steps I followed:
  1. Copy SDK FirstFoundatoinClient as a starting point.
  2. Set project dependency to SDK AlfrescoEmbedded
  3. Modify dir.root of custom-repository.properties
  4. Test run as application main of FirstFoundationClient
To create the project 'unit-test-webscripts', I copied the SDK's FirstFoundationClient in Eclipse and renamed it to 'unit-test-webscripts'. This project should already reference the SDK AlfrescoEmbedded project, and be configured to run Alfresco as Embedded, but it will need to be altered to run web scripts this way.
Modify the 'custom-repository.properties' file to reference the alfresco data root directory that you have configured when running Alfresco. It will be necessary to reference the same dir.root as alfresco when it is run standalone to share a database instance on the same machine, and allow you to test in either embedded mode or remote mode. I also added the line 'index.recovery.mode=FULL' to force the indexer to recover any out of sync references.

At this point, you should be able to run the 'main' method of the FirstFoundationClass of this project with eclipse' 'Run as Application' to make sure all is well.

NOTE: This step failed for me with a class not found error until I included a library reference to: alfresco-jlan-embed.jar in the java build path of the SDK AlfrescoEmbedded project and exported it.

After running, you should see in the console the line 'Alfresco started' to indicate the test was completed. The result of the run should be a new content item created in the repository with the name 'Foundation API sample (current Time in Milliseconds)' within the 'Company Home' folder. Start tomcat where alfresco is deployed and log in to view the Company Home folder. You should see the new files added. Make sure to stop tomcat before running embedded tests.

Step 3 - Configure to Run Web scripts via embedded alfresco

Once the project is copied and we can run the test class, we can now configure it to run our test webscripts. To summarize the steps:

  1. Create a source folder 'test' to contain configuration files and unit test code
  2. Create a folder 'alfresco' within the 'test' source folder to which we will copy the necessary spring context files
  3. copy web-scripts-application-context.xml from $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco to test/alfresco
  4. copy webscript-framework-application-context.xml from $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco to test/alfresco
  5. copy web-scripts-application-context-test.xml from $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco to test/alfresco
  6. comment out web Script messages bean 'webscripts.resources' of webscript-framework-application-context.xml
  7. copy web-scripts-config.xml from $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco to test/alfresco
  8. comment out bean 'webscript.org.alfresco.repository.dictionary.getchildassoc.get' from web-scripts-application-context.xml since class 'org.afresco.repo.web.scripts.dictionary.GetChildAssociationDef' no longer exists in jars
  9. comment out bean 'webscript.org.alfresco.repository.dictionary.getchildassocs.get' from web-scripts-application-context.xml since class 'org.afresco.repo.web.scripts.dictionary.GetChildAssociationDefs' no longer exists in jars
  10. create required directory source/alfresco/templates/webscripts
  11. create required directory source/alfresco/webscripts
  12. copy webscript-framework-config.xml from $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco to test/alfresco
  13. create required directory source/alfresco/templates/activities
  14. create directory source/alfresco/extension/templates/webscripts where our webscritps will go
  15. copy 'status.ftl' from $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco to test/alfresco/extension/templates/webscripts to the new extension/templates/webscripts directory to provide a default status template
Now the project is set up to run our webscripts.

Step 4 - Write the unit test and webscript

Add our data webscript to test into the source/alfresco/extension/templates/webscripts folder
Create 'test.get.desc.xml' with the following contents:
<webscript>
<shortname>test</shortname>
<description>Return runas user name</description>
<format>argument</format>
<url>/someco/test</url>
<authentication runas="RunAsOne">user</authentication>
<transaction>required</transaction>
</webscript>
This description describes the webscript 'someco/test' that will run as the user RunAsOne.
Create 'test.get.html.ftl' with the following contents:
${userName!"<notset>"}
This template will display the value of userName.
Create 'test.get.js' with the following code:
model.userName = person.properties.userName;
This script will set the value of userName with the current userName we are running as.
Thats it for the webscript. Now create the unit test into the test/org/someco directory called 'RunAsTest' to test the data webscript:
package org.someco;

import org.alfresco.model.ContentModel;
import org.alfresco.repo.web.scripts.BaseWebScriptTest;
import org.alfresco.service.cmr.security.AuthenticationService;
import org.alfresco.service.cmr.security.PersonService;
import org.alfresco.util.PropertyMap;
import org.alfresco.web.scripts.TestWebScriptServer.GetRequest;
import org.alfresco.web.scripts.TestWebScriptServer.Response;

public class RunAsTest extends BaseWebScriptTest {
private AuthenticationService authenticationService;
private PersonService personService;

private static final String USER_ONE = "RunAsOne";

private static final String URL_GET_CONTENT = "/someco/test";

@Override
protected void setUp() throws Exception
{
super.setUp();

this.authenticationService = (AuthenticationService) getServer().getApplicationContext().getBean(
"AuthenticationService");
this.personService = (PersonService) getServer().getApplicationContext().getBean("PersonService");

// Create users
createUser(USER_ONE);
}

private void createUser(String userName)
{
if (this.authenticationService.authenticationExists(userName) == false)
{
this.authenticationService.createAuthentication(userName, "PWD".toCharArray());

PropertyMap ppOne = new PropertyMap(4);
ppOne.put(ContentModel.PROP_USERNAME, userName);
ppOne.put(ContentModel.PROP_FIRSTNAME, "firstName");
ppOne.put(ContentModel.PROP_LASTNAME, "lastName");
ppOne.put(ContentModel.PROP_EMAIL, "email@email.com");
ppOne.put(ContentModel.PROP_JOBTITLE, "jobTitle");

this.personService.createPerson(ppOne);
}
}

@Override
protected void tearDown() throws Exception
{
super.tearDown();
}

public void testRunAs() throws Exception
{
Response response = sendRequest(new GetRequest(URL_GET_CONTENT), 200, "admin");
assertEquals(USER_ONE, response.getContentAsString());
}

}

This test creates 'UserOne', runs the 'someco/test' webscript, and checks the result code as 200 (success) and to see if the user name this script 'run as' was returned in the response.

Step 5 - Run the unit test

In order to see results, you should copy the log4j.properties file from $TOMCAT_HOME/webapps/alfresco/WEB_INF/classes into your source directory and configure log4j.properties to show debugging information for running web scripts by setting 'log4j.logger.org.alfresco.web.scripts=debug' and 'log4j.logger.org.alfresco.repo.web.scripts=debug' as recommended in the logging session of the 3.0 Web scripts Framework page of alfresco wiki.

To run the test, simply select the test/com/someco/RunAsTest class in Eclipse and select the 'run as' JUnit test. The test should pass and the console should show something like 'Processed script url (get) /someco/test in 517.601ms' as its last line.

Step 6 - Deploy the web script
Once confirmed, the web script tested can be deployed either by uploading it to the Company Home/Data Dictionary/Web Scripts Extensions space or add the web scripts to the $TOMCAT_HOME/webapps/alfresco/WEB-INF/classes/alfresco/extension/templates/webscripts folder and restarting alfresco.

Monday, December 8, 2008

Extending Share 2 - Adding a New Content button to Document Library

In the Extending Share 1 blog entry, we created a share extension project. Now we will write code to add a 'new content' button to the document library section of Share, based on the code created in the learning surf 1-6 blog entries.
NOTE: the complete sourceocde for this project can be downloaded from here.
Step 1 - Create a new form component
We will create a ui web script to open a create content form. In the 'config/alfresco/site-webscripts' source folder in package 'com.orbitz.components.documentlibrary', add the following files:

create-content.get.desc.xml
<webscript>
<shortname>create-content</shortname>
<description>Create Content module primarily for Document Library</description>
<url>/components/documentlibrary/create-content</url>
</webscript>
create-content.get.html.ftl
<div id="${args.htmlid}-dialog" class="create-folder">
<div class="hd">${msg("title")}</div>
<div class="bd">
<form id="${args.htmlid}-form" action="" method="post" accept-charset="utf-8">
<div class="yui-g">
<h2>${msg("header")}:</h2>
</div>
<div class="yui-gd">
<div class="yui-u first"><label for="${args.htmlid}-name">${msg("label.name")}:</label></div>
<div class="yui-u"><input id="${args.htmlid}-name" type="text" name="name" tabindex="1" />&nbsp;*</div>
</div>
<div class="yui-gd">
<div class="yui-u first"><label for="${args.htmlid}-title">${msg("label.title")}:</label></div>
<div class="yui-u"><input id="${args.htmlid}-title" type="text" name="title" tabindex="2" /></div>
</div>
<div class="yui-gd">
<div class="yui-u first"><label for="${args.htmlid}-description">${msg("label.description")}:</label></div>
<div class="yui-u"><textarea id="${args.htmlid}-description" name="description" rows="3" cols="20" tabindex="3" ></textarea></div>
</div>
<div class="yui-gd">
<div class="yui-u first"><label for="${args.htmlid}-content">${msg("label.content")}:</label></div>
<div class="yui-u"><textarea id="${args.htmlid}-content" name="content" rows="3" cols="20" tabindex="3" ></textarea></div>
</div>
<div class="bdft">
<input type="button" id="${args.htmlid}-ok" value="${msg("button.ok")}" tabindex="4" />
<input type="button" id="${args.htmlid}-cancel" value="${msg("button.cancel")}" tabindex="5" />
</div>
</form>
</div>
</div>

</code></pre>
create-content.get.properties
## Titles
title=New Content
header=New Content Details

## Labels
label.name=Name
label.title=Title
label.description=Description
label.content=Content
To test the new form,
  1. Deploy the web script by running the 'deploy' an task. (Make sure that share was started at least once so that the share war is expanded in the webapps/share directory of tomcat, and that the APP_TOMCAT_HOME and TOMCAT_HOME is properly set. This will copy the web scripts to the share directory.
  2. Start tomcat
  3. Check the ui web script by going to the url http://localhost:8080/share/service/components/documentlibrary/create-content?htmlid which should render the form (without any CSS, though).

Step 2 - Create a new toolbar for Document Library adding the 'New Content' button
The document library toolbar will be modified to render the 'New Content' button linked to an action handler that will open the create-content form via the simple dialog mechanism. To do this, we will create a new toolbar component '/orbitz/components/documentlibrary/toolbar' that will augment the existing '/components/documentlibrary/toolbar' with minimal code replication. This is a bit dangerous since it is entirely possible that the document library toolbar will change over time, so perhaps overriding it in this way is not the best approach, however, I have tried to minimize the exposure to code change as much as possible. if others have a better approach, I would gladly accept it. The source for this new component will also be in the 'config/alfresco/site-webscripts' source folder in the 'com.orbitz.components.documentlibrary' package, with the following files:
toobar.get.desc.xml
This component description alters the url to '/orbitz/component/documentlibrary/toolbar' which will have to be mapped for the 'toolbar' the region of the documentlibary template.
<webscript>
<shortname>DocLib Toolbar</shortname>
<description>Document Library: Toolbar Component</description>
<url>/orbitz/components/documentlibrary/toolbar</url>
</webscript>

toolbar.get.head.ftl
This is a copy of the original 'toolbar.get.head.ftl' with the addition of DocListOrbitzToolbar Assets including 'orbitz.toolbar.css' and 'orbitz.toolbar.js' which provide custom styles and behavior for the 'New Content' button.
<!-- DocListOrbitzToolbar Assets -->
<link rel="stylesheet" type="text/css" href="${page.url.context}/components/documentlibrary/orbitz.toolbar.css" />
<script type="text/javascript" src="${page.url.context}/components/documentlibrary/orbitz.toolbar.js"></script>
<!-- DocListToolbar Assets -->
<link rel="stylesheet" type="text/css" href="${page.url.context}/components/documentlibrary/toolbar.css" />
<script type="text/javascript" src="${page.url.context}/components/documentlibrary/toolbar.js"></script>
<!-- Simple Dialog Assets -->
<script type="text/javascript" src="${page.url.context}/modules/simple-dialog.js"></script>
<!-- File-Upload Assets -->
<link rel="stylesheet" type="text/css" href="${page.url.context}/modules/flash-upload.css" />
<script type="text/javascript" src="${page.url.context}/modules/flash-upload.js"></script>
<link rel="stylesheet" type="text/css" href="${page.url.context}/modules/html-upload.css" />
<script type="text/javascript" src="${page.url.context}/modules/html-upload.js"></script>
<script type="text/javascript" src="${page.url.context}/modules/file-upload.js"></script>

toolbar.get.html.ftl
This is a copy of the original 'toolbar.get.html.ftl' with a new javascript block instantiating 'alfresco.DocListOrbitzToolbar' as well as 'Alfresco.DocListToolbar'. This new javascript object will be defined in the new javascript file 'orbitz.toolbar.js' referenced in 'toolbar.get.head.ftl' above. Also, the 'new-content' button is added beneath the 'new folder' button.

<script type="text/javascript">//<![CDATA[
new Alfresco.DocListToolbar("${args.htmlid}").setOptions(
{
siteId: "${page.url.templateArgs.site!""}"
}).setMessages(
${messages}
);
new Alfresco.DocListOrbitzToolbar("${args.htmlid}").setOptions(
{
siteId: "${page.url.templateArgs.site!""}"
}).setMessages(
${messages}
);
//]]></script>
<div id="${args.htmlid}-body" class="toolbar">

<div id="${args.htmlid}-headerBar" class="header-bar flat-button">
<div class="new-folder hideable DocListTree"><button id="${args.htmlid}-newFolder-button" name="newFolder">${msg("button.new-folder")}</button></div>
<div class="new-content hideable DocListTree"><button id="${args.htmlid}-newContent-button" name="newContent">${msg("button.new-content")}</button></div>
<div class="separator hideable DocListTree">&nbsp;</div>
<div class="file-upload hideable DocListTree"><button id="${args.htmlid}-fileUpload-button" name="fileUpload">${msg("button.upload")}</button></div>
<div class="separator hideable DocListTree">&nbsp;</div>
<div class="selected-items hideable DocListTree DocListFilter DocListTags">
<button class="no-access-check" id="${args.htmlid}-selectedItems-button" name="doclist-selectedItems-button">${msg("menu.selected-items")}</button>
<div id="${args.htmlid}-selectedItems-menu" class="yuimenu">
<div class="bd">
<ul>
<li><a rel="" href="#"><span class="onActionCopyTo">${msg("menu.selected-items.copy")}</span></a></li>
<li><a rel="" href="#"><span class="onActionMoveTo">${msg("menu.selected-items.move")}</span></a></li>
<li><a rel="delete" href="#"><span class="onActionDelete">${msg("menu.selected-items.delete")}</span></a></li>
<li><a type="document" rel="" href="#"><span class="onActionAssignWorkflow">${msg("menu.selected-items.assign-workflow")}</span></a></li>
<li><a rel="permissions" href="#"><span class="onActionManagePermissions">${msg("menu.selected-items.manage-permissions")}</span></a></li>
<li><hr/></li>
<li><a rel="" href="#"><span class="onActionDeselectAll">${msg("menu.selected-items.deselect-all")}</span></a></li>
</ul>
</div>
</div>
</div>
<div class="rss-feed"><button id="${args.htmlid}-rssFeed-button" name="rssFeed">${msg("link.rss-feed")}</button></div>
</div>

<div id="${args.htmlid}-navBar" class="nav-bar flat-button">
<div class="folder-up hideable DocListTree"><button class="no-access-check" id="${args.htmlid}-folderUp-button" name="folderUp">${msg("button.up")}</button></div>
<div class="separator hideable DocListTree">&nbsp;</div>
<div id="${args.htmlid}-breadcrumb" class="breadcrumb hideable DocListTree"></div>
<div id="${args.htmlid}-description" class="description hideable DocListFilter DocListTags"></div>
</div>

</div>


toobar.get.properties
This is a copy of the original toolbar.get.properties with only a new couple of new properties:
  • button.new-content
  • message.new-content
## Buttons
button.delete=Delete
button.new-folder=New Folder
button.new-content=New Content
button.up=Up
button.upload=Upload

## Links
link.rss-feed=RSS Feed

## Drop-down Menus
menu.selected-items=Selected Items...
menu.selected-items.copy=Copy to...
menu.selected-items.move=Move to...
menu.selected-items.delete=Delete
menu.selected-items.assign-workflow=Assign Workflow...
menu.selected-items.manage-permissions=Manage Permissions...
menu.selected-items.deselect-all=Deselect All

## Pop-up Messages
message.new-folder.success=Folder '{0}' created
message.new-content.success=Content '{0}' created
message.new-folder.failure=Could not create '{0}'
message.new-content.failure=Could not create '{0}'
message.multiple-delete.success=Successfully deleted {0} item(s)
message.multiple-delete.failure=Could not delete items
title.multiple-delete.confirm=Confirm Multiple Delete
message.multiple-delete.confirm=Are you sure you want to delete the following {0} items?
message.multiple-delete.please-wait=Please wait. Files being deleted...

## Toolbar Modes
description.path=
description.path.more=
description.all=All Documents in the Document Library
description.all.more=
description.editingMe=Documents I'm Editing
description.editingMe.more=(working copies)
description.editingOthers=Documents Others are Editing
description.editingOthers.more=(working copies)
description.recentlyModified=Documents Recently Modified
description.recentlyModified.more=
description.recentlyAdded=Documents Added Recently
description.recentlyAdded.more=
description.tag=Documents and Folders Tagged with
description.tag.more={0}
Step 3, create the javascript to support the toolbar

Within the 'source/web' source folder 'components.documentlibrary' package, the new javascript and css assets referenced in 'toolbar.get.head.ftl' are defined:

orbitz.toolbar.js
This javascript creates a new object: 'Alfresco.DocListOrbitzToolbar' instantiated in 'toolbar.get.html.ftl' that decorates the 'newContent' button as a YUI button mapped to the 'onNewContent' action handler to open the new-content form using the SimpleDialog mechanism
/**
* DocumentList Toolbar component.
*
* @namespace Alfresco
* @class Alfresco.DocListToolbar
*/
(function()
{
/**
* YUI Library aliases
*/
var Dom = YAHOO.util.Dom,
Event = YAHOO.util.Event,
Element = YAHOO.util.Element;

/**
* Alfresco Slingshot aliases
*/
var $html = Alfresco.util.encodeHTML;

/**
* DocListToolbar constructor.
*
* @param {String} htmlId The HTML id of the parent element
* @return {Alfresco.DocListToolbar} The new DocListToolbar instance
* @constructor
*/
Alfresco.DocListOrbitzToolbar = function(htmlId)
{
// Mandatory properties
this.name = "Alfresco.DocListOrbitzToolbar";
this.id = htmlId;

// Initialise prototype properties
this.widgets = {};
this.modules = {};
this.selectedFiles = [];
this.currentFilter =
{
filterId: "",
filterOwner: "",
filterData: ""
};

// Register this component
Alfresco.util.ComponentManager.register(this);

// Load YUI Components
Alfresco.util.YUILoaderHelper.require(["button", "menu", "container"], this.onComponentsLoaded, this);

// Decoupled event listeners
//YAHOO.Bubbling.on("pathChanged", this.onPathChanged, this);
//YAHOO.Bubbling.on("folderRenamed", this.onPathChanged, this);
//YAHOO.Bubbling.on("filterChanged", this.onFilterChanged, this);
YAHOO.Bubbling.on("deactivateAllControls", this.onDeactivateAllControls, this);
//YAHOO.Bubbling.on("selectedFilesChanged", this.onSelectedFilesChanged, this);
YAHOO.Bubbling.on("userAccess", this.onUserAccess, this);

return this;
};

Alfresco.DocListOrbitzToolbar.prototype =
{
/**
* Object container for initialization options
*
* @property options
* @type object
*/
options:
{
/**
* Current siteId.
*
* @property siteId
* @type string
*/
siteId: "",

/**
* ContainerId representing root container
*
* @property containerId
* @type string
* @default "documentLibrary"
*/
containerId: "documentLibrary",

/**
* Number of multi-file uploads before grouping the Activity Post
*
* @property groupActivitiesAt
* @type int
* @default 5
*/
groupActivitiesAt: 5,

/**
* Flag indicating whether navigation bar is visible or not.
*
* @property hideNavBar
* @type boolean
*/
hideNavBar: false
},

/**
* Current path being browsed.
*
* @property currentPath
* @type string
*/
currentPath: "",

/**
* Current filter to choose toolbar view and populate description.
*
* @property currentFilter
* @type string
*/
currentFilter: null,

/**
* FileUpload module instance.
*
* @property fileUpload
* @type Alfresco.module.FileUpload
*/
fileUpload: null,

/**
* Object container for storing YUI widget instances.
*
* @property widgets
* @type object
*/
widgets: null,

/**
* Object container for storing module instances.
*
* @property modules
* @type object
*/
modules: null,

/**
* Array of selected states for visible files.
*
* @property selectedFiles
* @type array
*/
selectedFiles: null,

/**
* Set multiple initialization options at once.
*
* @method setOptions
* @param obj {object} Object literal specifying a set of options
* @return {Alfresco.DocListToolbar} returns 'this' for method chaining
*/
setOptions: function DLTB_setOptions(obj)
{
this.options = YAHOO.lang.merge(this.options, obj);
return this;
},

/**
* Set messages for this component.
*
* @method setMessages
* @param obj {object} Object literal specifying a set of messages
* @return {Alfresco.DocListToolbar} returns 'this' for method chaining
*/
setMessages: function DLTB_setMessages(obj)
{
Alfresco.util.addMessages(obj, this.name);
return this;
},

/**
* Fired by YUILoaderHelper when required component script files have
* been loaded into the browser.
*
* @method onComponentsLoaded
*/
onComponentsLoaded: function DLTB_onComponentsLoaded()
{
Event.onContentReady(this.id, this.onReady, this, true);
},

/**
* Fired by YUI when parent element is available for scripting.
* Component initialisation, including instantiation of YUI widgets and event listener binding.
*
* @method onReady
*/
onReady: function DLTB_onReady()
{

// New Content button: user needs "create" access
this.widgets.newContent = Alfresco.util.createYUIButton(this, "newContent-button", this.onNewContent,
{
disabled: true,
value: "create"
});

// Finally show the component body here to prevent UI artifacts on YUI button decoration
//Dom.setStyle(this.id + "-body", "visibility", "visible");
},


/**
* YUI WIDGET EVENT HANDLERS
* Handlers for standard events fired from YUI widgets, e.g. "click"
*/



/**
* New Content button click handler
*
* @method onNewContent
* @param e {object} DomEvent
* @param p_obj {object} Object passed back from addListener method
*/
onNewContent: function DLTB_onNewContent(e, p_obj)
{
var actionUrl = YAHOO.lang.substitute(Alfresco.constants.PROXY_URI + "slingshot/doclib/action/folder/site/{site}/{container}/{path}",
{
site: this.options.siteId,
container: this.options.containerId,
path: this.currentPath
});

var doSetupFormsValidation = function DLTB_oNF_doSetupFormsValidation(p_form)
{
// Validation
// Name: mandatory value
p_form.addValidation(this.id + "-createContent-name", Alfresco.forms.validation.mandatory, null, "keyup");
// Name: valid filename
p_form.addValidation(this.id + "-createContent-name", Alfresco.forms.validation.nodeName, null, "keyup");
p_form.setShowSubmitStateDynamically(true, false);
};

if (!this.modules.createContent)
{
this.modules.createContent = new Alfresco.module.SimpleDialog(this.id + "-createContent").setOptions(
{
width: "30em",
templateUrl: Alfresco.constants.URL_SERVICECONTEXT + "components/documentlibrary/create-content",
actionUrl: actionUrl,
doSetupFormsValidation:
{
fn: doSetupFormsValidation,
scope: this
},
firstFocus: this.id + "-createContent-name",
onSuccess:
{
fn: function DLTB_onCreateContent_callback(response)
{
var file = response.json.results[0];
YAHOO.Bubbling.fire("folderCreated",
{
name: file.name,
parentPath: file.parentPath,fileCopied
nodeRef: file.nodeRef
});
Alfresco.util.PopupManager.displayMessage(
{
text: this._msg("message.new-content.success", file.name)
});
},
scope: this
}
});
}
else
{
this.modules.createContent.setOptions(
{
actionUrl: actionUrl,
clearForm: true
});
}
this.modules.createContent.show();
},
/**
* Deactivate All Controls event handler
*
* @method onDeactivateAllControls
* @param layer {object} Event fired
* @param args {array} Event parameters (depends on event type)
*/
onDeactivateAllControls: function DLTB_onDeactivateAllControls(layer, args)
{
var widget;
for (widget in this.widgets)
{
if (this.widgets.hasOwnProperty(widget))
{
this.widgets[widget].set("disabled", true);
}
}
},

/**
* User Access event handler
*
* @method onUserAccess
* @param layer {object} Event fired
* @param args {array} Event parameters (depends on event type)
*/
onUserAccess: function DLTB_onUserAccess(layer, args)
{
var obj = args[1];
if ((obj !== null) && (obj.userAccess !== null))
{
var widget, widgetPermissions, index;
for (index in this.widgets)
{
if (this.widgets.hasOwnProperty(index))
{
widget = this.widgets[index];
if (widget.get("srcelement").className != "no-access-check")
{
widget.set("disabled", false);
if (widget.get("value") !== null)
{
widgetPermissions = widget.get("value").split(",");
for (var i = 0, ii = widgetPermissions.length; i < ii; i++)
{
if (!obj.userAccess[widgetPermissions[i]])
{
widget.set("disabled", true);
break;
}
}
}
}
}
}
}
},

/**
* Gets a custom message
*
* @method _msg
* @param messageId {string} The messageId to retrieve
* @return {string} The custom message
* @private
*/
_msg: function DLTB__msg(messageId)
{
return Alfresco.util.message.call(this, messageId, "Alfresco.DocListOrbitzToolbar", Array.prototype.slice.call(arguments).slice(1));
}

};
})();
This script at first will be configured to run the web script to create a new folder:
var actionUrl = YAHOO.lang.substitute(Alfresco.constants.PROXY_URI + "slingshot/doclib/action/folder/site/{site}/{container}/{path}",
In the following steps, we will create our own script that will create a new file.

orbitz.toolbar.css

This css defines the images and style used for the 'newContent' button:
.toolbar .new-content button
{
background: transparent url(images/content-new-16.png) no-repeat 12px 4px;
padding-left: 32px;
}

.toolbar .new-content .yui-button-disabled button
{
background-image: url(images/content-new-disabled-16.png);
}
these images are copies of the slingshot project's 'source/web/components/images/edit-blog-16.png' image.

Step 4 - Create the component to map to the 'toolbar' region of the document library template

Finally in the 'config/alfresco/site-data' source folder in the 'components' package, we override the existing 'toolbar' region in the 'documentlibrary' template to reference the /orbitz/components/documentlibrary/toolbar' component:

template.toolbar.documentlibrary.xml
<?xml version='1.0' encoding='UTF-8'?>
<component>
<scope>template</scope>
<region-id>toolbar</region-id>
<source-id>documentlibrary</source-id>
<url>/orbitz/components/documentlibrary/toolbar</url>
</component>
Step 5 - Deploy and Test
First, we need to make sure the 'share' war is installed and expanded in the tomcat host. I use the Alfresco project build.xml's
ant incremental-slingshot-tomcat-exploded
But for a war distribtuion, just make sure it is run at least once.

To use the build.xml, make sure to set APP_TOMCAT_HOME and TOMCAT_HOME environment variables as recommended in the previous blog article: Extending Share 1 - Creating a Share Extension Project

To build and deploy the share webapp expanded in the tomcat directory. Now we can deploy our extension code from the 'deals-share-extension' project's build.xml file:
ant deploy
This will create the 'deals-share-ext.jar' jar file and copy to the share/WEB-INF/lib directory, copy the configuration files from the config/alfresco/** source folders to the share/weB-INF/classes directory, and copy the web assets from the source/web source folders to the share root directory.

We can test our toolbar code directly using the url: http://localhost:8080/share/service/orbitz/components/documentlibrary/toolbar?htmlid , but this will not render with CSS, and without CSS, the YUI components wont work.

Now we can test within the Share:
  1. Launch share http://localhost:8080/share, opens to the share dashboard
  2. Create and/or select a Site, opens to the site dashboard
  3. Select the 'document library' section should render our new toolbar with the 'New Content' button
  4. Press the 'New Content' button and the 'create-content' form should open
  5. Enter the new content's name, title, description and content int he form and press 'ok' and the dialog should close, but instead of a file created, we should see a new folder created. In the next steps we will create the web script necessary to create a new file.
Step 6 - Creating a custom web script action

Now we need to create a webscript service to use instead of the slingshot/doclib/action/folder service. for this blog, I will extend the action services to implement the following url pattern:

POST slingshot/doclib/action/file/site/{site}/{container}/{path}

To do this, we will add a new webscript service to the remote alfresco.war by uploading it to the Data Dictionary space. Later, we can separate this to our own amp module. As a starting point, we will copy the folder.post.* files to create file.post.* files in Remote API/config/alfresco/templates/webscripts/org/alfresco/slingshot/documentlibrary/action directory to create the following files:
  • file.post.desc.xml
  • file.post.json.ftl
  • file.post.json.js
Now we can modify these files to do the work of creating a file instead of a folder. for 'file.post.desc.xml' we modify the url pattern to the following:
<webscript>
<shortname>folder</shortname>
<description>Document List Action - Create folder</description>
<url>/slingshot/doclib/action/file/site/{site}/{container}</url>
<url>/slingshot/doclib/action/file/site/{site}/{container}/{path}</url>
<format default="json">argument</format>
<authentication>user</authentication>
<transaction>required</transaction>
</webscript>

file.post.json.ftl will be changed, it imports the action.lib.ftl to create a standard results response json format. But I will combine the import. Its contents are the following:
<#macro resultsJSON results>
<#escape x as jsonUtils.encodeJSONString(x)>
{
"totalResults": ${results?size},
"overallSuccess": ${overallSuccess?string},
"successCount": ${successCount},
"failureCount": ${failureCount},
"results":
[
<#list results as r>
{
<#list r?keys as key>
<#assign value = r[key]>
<#if value?is_number || value?is_boolean>
"${key}": ${value?string}<#if key_has_next>,</#if>
<#else>
"${key}": "${value}"<#if key_has_next>,</#if>
</#if>
</#list>
}<#if r_has_next>,</#if>
</#list>
]
}
</#escape>
</#macro>
<@resultsJSON results=results />

file.post.json.js needs to be modified to handle a file instead of a folder.

The main changes I will make to this script:
  1. get a 'content' field from the json request object from the create-content form that we added previously.
     if (!json.isNull("content"))
    {
    content = json.get("content");
    }
  2. Rename variables folderName, folderDescription, folderTitle, folderPath to fileName, fileDescription, fileTitle, fileDescription and filePath respectively.
  3. Create a file node instead of a folder node
    var fileNode = parentNode.createFile(fileName);

  4. set the content on the newly created fileNode variable arfter the fileNode.save(); method
          fileNode.save();
    // Add uifacets aspect for the web client
    fileNode.content = content;
  5. Change messaging to reflect saving a file, not a folder.
The completed file.post.json.js looks like this:
/**
* Document List Component: action
*
* For a single-asset action, template paramters address the asset.
* For multi-asset actions, template parameters address the source or destination node,
* and a JSON body addresses the assets involved in the action.
* (note: HTTP DELETE methods must use URI)
*
* @param uri {string} site/{siteId}/{containerId}/{filepath} : full path to file or folder name involved in the action
* @param uri {string} node/{store_type}/{store_id}/{id}/{filepath} : full path to file or folder name involved in the action
*/

/**
* Main script entry point
* @method main
*/
function main()
{
// Params object contains commonly-used arguments
var params = {};
var files, rootNode;

if (url.templateArgs.store_type != undefined)
{
params = getNodeRefInputParams();
}
else if (url.templateArgs.site != undefined)
{
params = getSiteInputParams();
}
if (typeof params == "string")
{
status.setCode(status.STATUS_BAD_REQUEST, params);
return;
}

// Resolve path if available
var path = url.templateArgs.path;
// Path might be null for the root folder
if (!path)
{
path = "";
}
// Remove any leading or trailing "/" from the path
// Fix-up parent path to have no leading or trailing slashes
if (path.length > 0)
{
var aPaths = path.split("/");
while (aPaths[0] === "")
{
aPaths.shift();
}
while (aPaths[aPaths.length-1] === "")
{
aPaths.pop();
}
path = aPaths.join("/");
}
params.path = path;

// Multiple input files in the JSON body?
files = getMultipleInputValues("nodeRefs");
if (typeof files != "string")
{
params.files = files;
}

// Check runAction function is provided the action's webscript
if (typeof runAction != "function")
{
status.setCode(status.STATUS_BAD_REQUEST, "Action webscript must provide runAction() function.");
return;
}

// Actually run the action
var results = runAction(params);
if ((results !== null) && (results !== undefined))
{
if (typeof results == "string")
{
status.setCode(status.STATUS_INTERNAL_SERVER_ERROR, results);
}
else if (typeof results.status == "object")
{
// Status fields have been manually set
status.redirect = true;
for (var s in results.status)
{
status[s] = results.status[s];
}
}
else
{
/**
* NOTE: Webscripts run within one transaction only.
* If a single operation fails, the transaction is marked for rollback and all
* previous (successful) operations are also therefore rolled back.
* We therefore need to scan the results for a failed operation and mark the entire
* set of operations as failed.
*/
var overallSuccess = true;
var successCount = 0;
var failureCount = 0;
for (var i = 0, j = results.length; i < j; i++)
{
overallSuccess = overallSuccess && results[i].success;
results[i].success ? ++successCount : ++failureCount;
}
model.overallSuccess = overallSuccess;
model.successCount = successCount;
model.failureCount = failureCount;
model.results = results;
}
}
}


/**
* Get and check existence of mandatory input parameters (Site-based)
*
* @method getSiteInputParams
* @return {object|string} object literal containing parameters value or string error
*/
function getSiteInputParams()
{
var params = {};
var error = null;
var template = url.template;

try
{
var siteId, containerId, sideNode, rootNode;

// Try to get the parameters from the URI
siteId = url.templateArgs.site;
containerId = url.templateArgs.container;

// SiteId
if (template.indexOf("{site}") != -1)
{
if ((siteId === null) || (siteId.length === 0))
{
return "'site' parameter is missing.";
}

// Find the site
siteNode = siteService.getSite(siteId);
if (siteNode === null)
{
return "Site '" + siteId + "' not found.";
}

// ContainerId
if (template.indexOf("{container}") != -1)
{
if ((containerId === null) || (containerId.length === 0))
{
return "'container' parameter is missing.";
}

// Find the component container
var rootNode = siteNode.getContainer(containerId);
if (rootNode === null)
{
rootNode = siteNode.createContainer(containerId);
if (rootNode === null)
{
return "Component container '" + containerId + "' not found in '" + siteId + "'.";
}
}
}

// Populate the return object
params =
{
usingNodeRef: false,
siteId: siteId,
containerId: containerId,
siteNode: siteNode,
rootNode: rootNode
}
}
}
catch(e)
{
error = e.toString();
}

// Return the params object, or the error string if it was set
return (error !== null ? error : params);
}

/**
* Get and check existence of mandatory input parameters (nodeRef-based)
*
* @method getNodeRefInputParams
* @return {object|string} object literal containing parameters value or string error
*/
function getNodeRefInputParams()
{
var params = {};
var error = null;

try
{
// First try to get the parameters from the URI
var storeType = url.templateArgs.store_type;
var storeId = url.templateArgs.store_id;
var id = url.templateArgs.id;

var nodeRef = storeType + "://" + storeId + "/" + id;
var rootNode = null;

if (nodeRef == "alfresco://company/home")
{
rootNode = companyhome;
}
else if (nodeRef == "alfresco://user/home")
{
rootNode = userhome;
}
else
{
rootNode = search.findNode(nodeRef);

if (rootNode === null)
{
return "'" + nodeRef + "' is not a valid nodeRef.";
}
}

// Populate the return object
params =
{
usingNodeRef: true,
nodeRef: nodeRef,
rootNode: rootNode
}
}
catch(e)
{
error = e.toString();
}

// Return the params object, or the error string if it was set
return (error !== null ? error : params);
}

/**
* Get multiple input values
*
* @method getMultipleInputValues
* @return {array|string} Array containing multiple values, or string error
*/
function getMultipleInputValues(param)
{
var values = [];
var error = null;

try
{
// Was a JSON parameter list supplied?
if (typeof json == "object")
{
if (!json.isNull(param))
{
var jsonValues = json.get(param);
// Convert from JSONArray to JavaScript array
for (var i = 0, j = jsonValues.length(); i < j; i++)
{
values.push(jsonValues.get(i));
}
}
}
}
catch(e)
{
error = e.toString();
}

// Return the values array, or the error string if it was set
return (error !== null ? error : values);
}


/**
* Obtain the asset node for the given rootNode and filepath
*
* @method getAssetNode
* @param p_rootNode {object} valid repository node
* @param p_assetPath {string} rootNode-relative path to asset
* @return {object|string} valid repository node or string error
*/
function getAssetNode(p_rootNode, p_assetPath)
{
var assetNode = p_rootNode;
var error = null;

try
{
if (p_assetPath && (p_assetPath.length > 0))
{
assetNode = assetNode.childByNamePath(p_assetPath);
}

if (assetNode === null)
{
return "Asset '" + p_assetPath + " not found.";
}
}
catch(e)
{
error = e.toString();
}

// Return the node object, or the error string if it was set
return (error !== null ? error : assetNode);
}

/**
* Create file action
* @method POST
* @param uri {string} /{siteId}/{containerId}/{filepath}
* @param json.name {string} New file name
* @param json.title {string} Title metadata
* @param json.description {string} Description metadata
* @param json.content {string} Content of file
*/

/**
* Entrypoint required by action.lib.js
*
* @method runAction
* @param p_params {object} common parameters
* @return {object|null} object representation of action result
*/
function runAction(p_params)
{
var results;

try
{
// Mandatory: json.name
if (json.isNull("name"))
{
status.setCode(status.STATUS_BAD_REQUEST, "File name is a mandatory parameter.");
return;
}
var fileName = json.get("name");

var parentPath = p_params.path;
var filePath = parentPath + "/" + fileName;

// Check file doesn't already exist
var existsNode = getAssetNode(p_params.rootNode, filePath);
if (typeof existsNode == "object")
{
status.setCode(status.STATUS_BAD_REQUEST, "File '" + filePath + "' already exists.");
return;
}

// Check parent exists
var parentNode = getAssetNode(p_params.rootNode, parentPath);
if (typeof parentNode == "string")
{
status.setCode(status.STATUS_NOT_FOUND, "Parent folder '" + parentPath + "' not found.");
return;
}

// Title and description
var fileTitle = "";
var fileDescription = "";
if (!json.isNull("title"))
{
fileTitle = json.get("title");
}
if (!json.isNull("description"))
{
fileDescription = json.get("description");
}
if (!json.isNull("content"))
{
content = json.get("content");
}
// Create the folder and apply metadata
var fileNode = parentNode.createFile(fileName);
// Always add title & description, default icon
fileNode.properties["cm:title"] = fileTitle;
fileNode.properties["cm:description"] = fileDescription.substr(0, 100);
fileNode.properties["app:icon"] = "space-icon-default";
fileNode.save();
// Add uifacets aspect for the web client
fileNode.content = content;
fileNode.addAspect("app:uifacets");

// Construct the result object
results = [
{
id: filePath,
name: fileName,
parentPath: parentPath,
nodeRef: fileNode.nodeRef.toString(),
action: "createFile",
success: true
}];
}
catch(e)
{
status.setCode(status.STATUS_INTERNAL_SERVER_ERROR, e.toString());
return;
}

return results;
}

/* Bootstrap action script */
main();

This service uses the action.lib.js and action.lib.ftl libraries to simplify coding of similar functions, including: checkin, checkout, cancel-checkout, copy-to, move-to, file or folder delete and others. The action.lib.js is included in the folder.post.json.js script and provides a main() entry point to do common setup, clean up and host shared functions. The main entry point calls the runAction() function defined in the specific action script 'folder.post.json.js', effectively implementing a strategy pattern.

Step 7 - upload, build and test
Log into the alfresco server as admin. Navigate to Company Home > Data Dictionary > Web Scripts > org > alfresco

Create a new directory 'doclib' and copy the three files in to this directory: 'file.post.desc.xml', 'file.post.json.ftl', and 'file.post.json.js'.
Navigate to http://localhost:8080/alfresco/service/index and select 'refresh webscripts' button.

To view the new service, use the url http://localhost:8080/alfresco/service/index/uri/slingshot/doclib/action/file/site/%7Bsite%7D/%7Bcontainer%7D which should show the service correctly added.

Step 8 - alter 'New Content' button action to invoke new service
Now we have to use this new service we have created when we press the 'new content' button orbitz-toolbar.js onNewContent action line to read:
         var actionUrl = YAHOO.lang.substitute(Alfresco.constants.PROXY_URI + "slingshot/doclib/action/file/site/{site}/{container}/{path}",

this will call our new slingshot service to create a file, which in turn will call the new remote api service we added to alfresco.

Step 9 - deploy and test
Now, you should be able to press 'New Content' in share Document Library and create a new content item.

Thursday, December 4, 2008

Extending Share 1 - Creating a Share extension project

This post builds on the Learning Surf 1-6 blog entries (titled Learning Surf 1-6) to build a project to extend Alfresco's Share with custom capabilities. My previous blog posts walked through the Surf framework by creating a new button 'New Content' added to the 'Document Library' section of Share which allows content to be created and edited in-line in a popup form.

To recap the steps in the 'Learning Surf' posts:
  1. Created a new ui webscript '/components/documentlibrary/create-content' that renders a form to capture the new content's name, title, description and contents
  2. Augmented the document library's toolbar component with a 'New Content' button which opens the new create-content form component in a light box / modal dialog using the SimpleDialog library mechanism (similar to the 'New Folder' button)
  3. Added a webscript service (POST /slingshot/actions/file) to the remote API to save the new content in the content repository by creating a new content item and adding it to the curernt path.
The code we wrote previously extended the Share (Slingshot project) and Alfresco (via the Remote API project) by directly coding in the code base of those projects. This is not an acceptable way to extend these products if not intending to become part of the main build. What is needed are two projects that are independent from the main code base and can add the desired functionality to the Share web client and the remote API on the content repository. The new webscript service previously added in the Remote API project will be created as an AMP module based project following the SDK Basic AMP project standard. I will describe this process in the next blog post. In this post, I will describe the steps to create a Share extension project. The approach and structure structure is my own. I am not sure this is the best approach to extend Share, but it does work. I am hoping to improve on this approach in the future (with your help of course).
NOTE: the complete sourceocde for this project can be downloaded from here.
Step 1 - Create a basic project structure
This project is created with Eclipse 'new java project' wizard, named 'deals-share-extension'. I created the following source folders (via File>New>Source Folder):
  • source/java - containing any java code (so far, none)
  • config/orbitz/site-webscripts - containing custom components (i.e. '/components/documentlibrary/create-content')
  • config/orbitz/templates - containing custom templates (so far, none)
  • config/alfresco/site-webscripts - containing override webscripts extending or replacing existing Share webscripts
  • config/alfresco/web-extension - contains extension spring bean and surf configurations to extend share (i.e. 'custom-slingshot-application-context.xml')
  • source/web containing any custom javascript, images and css
Also, the following was added to the project:
  • lib folder containing junit.jar for anticipated unit tests (more on these in the future)
  • build.xml ant build script used to build the project (described in the next step)
  • build.properties file containing configurable parameters for the build script (also described in the next step)
Created during the project build are the 'build/classes' folder containing java source and config files and the 'dist' folder containing the packaged jar file.

Step 2 - Create an Ant build.xml
The build script (see next step) will create a jar (named 'deals-share-ext.jar') containing any java code and any webscripts in the config/orbitz path, and deploy it to the WEB-INF/lib directory of the expanded Share war. In addition, the build script will copy the code in config/alfresco to the WEB-INF/classes/alfresco directory of the expanded Share war adding to or overwriting any existing Share components. Finally, source/web is copied to the Share root to add the custom Javascript, css and images used by the new components.

The build.xml ant script contains the following targets:
  • init - initializes build properties
  • incremental - incrementally builds the project (depends on package)
  • build - (default) cleans and builds the project (depends on clean and incremental)
  • clean - removes the 'build' and 'dist' directories and files created during compile and package
  • compile - compiles java code to build/classes and copies files required for the classpath into compiled classes folder and copies the deployable web assets to the build folder
  • package - creates a jar file in the dist folder from files in build/classes
  • deploy - copies jar to expanded share project war WEB-INF/lib, config files in build/classes 'alfresco' to WEB-INF/classes and web assets in source/web to the share root
  • test - runs unit tests
Here is the complete source for build.xml
<project name="deals-share-extension"
default="build" >

<target name="init" description="initialize build parameters">
<property file="${basedir}/build.properties" />

<path id="classpath.unit.test">

<pathelement location="${dir.name.classes}" />
<pathelement location="${dir.junit.lib}/junit.jar"/>
</path>
</target>


<target name="incremental" description="incremental build, no clean"
depends="package" />

<target name="build" description="builds entire project from clean"
depends="clean, incremental" />

<target name="clean">
<delete dir="${dir.name.build}" verbose="true" includeemptydirs="true"/>
</target>

<target name="compile"
depends="init">
<mkdir dir="${dir.name.build}/${dir.name.classes}" />
<javac destdir="${dir.name.build}/${dir.name.classes}" fork="true"
memoryMaximumSize="${mem.size.max}" deprecation="${javac.deprecation}"
debug="${javac.debug}" target="${javac.target}" source="${javac.source}" encoding="${javac.encoding}"
excludes="@{compileExcludes}" >
<src path="${dir.name.source}/${dir.name.java}" />
<classpath refid="classpath.compile" />

</javac>

<copy todir="${dir.name.build}/${dir.name.classes}">
<fileset dir="${dir.name.source}/${dir.name.java}">
<patternset>
<exclude name="**/*.java" />
<exclude name="log4j.properties" />
</patternset>
</fileset>
</copy>
<copy todir="${dir.name.build}/${dir.name.classes}">
<fileset dir="${dir.name.config}">
<patternset>
<exclude name="**/*.java" />
<exclude name="log4j.properties" />
</patternset>
</fileset>
</copy>
<copy todir="${dir.name.build}">
<fileset dir="${dir.name.source}/${dir.name.web}">
<patternset>
<exclude name="**/*.java" />
<exclude name="log4j.properties" />
</patternset>
</fileset>
</copy>
</target>

<target name="package" description="Creates jar file"
depends="compile">
<mkdir dir="${dir.name.dist}" />
<jar jarfile="${dir.name.dist}/${file.name.jar}"
basedir="${dir.name.build}/${dir.name.classes}" />
</target>

<target name="deploy" description="copy into share expanded share deployment"
depends="package">
<copy todir="${env.APP_TOMCAT_HOME}/webapps/share/WEB-INF/lib">
<fileset dir="${dir.name.dist}">
<patternset>
<include name="**/*.jar" />
</patternset>
</fileset>
</copy>

<copy todir="${env.APP_TOMCAT_HOME}/webapps/share/WEB-INF/classes">
<fileset dir="${dir.name.config}">
<patternset>
<include name="alfresco/**/*"/>

</patternset>
</fileset>
</copy>

<copy todir="${env.APP_TOMCAT_HOME}/webapps/share">
<fileset dir="${dir.name.source}/${dir.name.web}">
<patternset>
<include name="**/*.js"/>
<include name="**/*.css"/>

</patternset>
</fileset>
</copy>

</target>

<target name="test" description="run tests and generate results"
depends="compile">
<mkdir dir="${dir.name.build}/${dir.name.tes t.results}" />
<junit printsummary="yes" fork="yes" maxmemory="${mem.size.max}" haltonfailure="yes" dir="@{projectdir}">
<jvmarg value="-server"/>
<classpath refid="classpath.unit.test" />
<formatter type="xml" />
<batchtest todir="${dir.name.build}/${dir.name.test.results}">
<fileset dir="${dir.name.source}/${dir.name.java}">
<patternset includes="**/*Tests.java" />
</fileset>
</batchtest>
</junit>
</target>

</project>
Supporting the build.xml file is the build.properties file:

#directory names

dir.name.assemble=assemble
dir.name.bin=bin
dir.name.build=build
dir.name.classes=classes
dir.name.config=config
dir.name.source=source
dir.name.devenv=devenv
dir.name.dist=dist
dir.name.distro=distro
dir.name.docs=docs
dir.name.generated=generated
dir.name.java=java
dir.name.lib=lib
dir.name.test.results=
dir.name.test.results=test-results
dir.name.test.resources=test-resources
dir.name.web=web

file.name.jar=deals-share-ext.jar

dir.junit.lib=lib

Step 3 - Create an extension spring bean configuration
Within the slingshot project in /config/alfresco there is a spring context file 'slingshot-application-context.xml' which among other things defines search paths for web scripts and surf model objects. These spring beans are referred to from the 'web-framework-config-application.xml' defining the beans for finding the model types in surf. What is needed is to add a search path to identify the custom webscripts and model objects we will extend in share. One mechanism to do this is to create a new file under /config/alfresco/web-extension called 'custom-slingshot-application-context.xml' with search paths to find custom components, templates and web scripts.
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE beans PUBLIC '-//SPRING//DTD BEAN//EN' 'http://www.springframework.org/dtd/spring-beans.dtd'>

<beans>

<bean id="webframework.searchpath" class="org.alfresco.web.scripts.SearchPath">
<property name="searchPath">
<list>
<ref bean="dealscontent.store.webscripts" />
<ref bean="webframework.remotestore.webscripts" />
<ref bean="webframework.store.webscripts.custom" />
<ref bean="webframework.store.webscripts" />
<ref bean="webscripts.store" />
</list>
</property>
</bean>

<bean id="dealscontent.store.webscripts" class="org.alfresco.web.scripts.ClassPathStore">
<property name="mustExist"><value>true</value></property>
<property name="classPath"><value>orbitz/site-webscripts</value></property>
</bean>

</beans>
the 'webframework.searchpath' bean overrides a bean defined in the webscript project used to define search paths for webscript processors. Added to the search path list is a reference to a custom 'dealscontent.store.webscripts' bean which defines the specific folder to search in the classpath for our new webscripts as 'orbitz/site-webscripts'.


In the next blog entry, we will write code to add a 'new content' button to the document library's toolbar similar to the code written in the 'learning surf' blog entries.

Monday, December 1, 2008

Learning Surf 6 - Creating a webscript service

In the previous blog, we created a simple dialog to open a content creation form from Share's Document Library page toolbar when we click on our added 'New Content' button. This form invokes the POST for 'slingshot/doclib/action/folder/site/{site}/{container}/{path}' service implemented as part of the RESTful Remote API of Afresco 3.0. As we left it, the 'New Content' button simply creates a folder doing the same job as the 'New Folder' button.

Step 1 - create a new service

Now we need to create a webscript service to use instead of the slingshot/doclib/action/folder service. for this blog, I will extend the action services to implement the following url pattern:

POST slingshot/doclib/action/file/site/{site}/{container}/{path}

To do this, we will add a new webscript service to the remote api project. Later, we can separate this to our own amp module. As a starting point, we can copy the folder.post.* files to create file.post.* files in Remote API/config/alfresco/templates/webscripts/org/alfresco/slingshot/documentlibrary/action directory. This creates the following files:
  • file.post.desc.xml
  • file.post.json.ftl
  • file.post.json.js
Now we can modify these files to do the work of creating a file instead of a folder. for 'file.post.desc.xml' we modify the url pattern to the following:
<webscript>
<shortname>folder</shortname>
<description>Document List Action - Create folder</description>
<url>/slingshot/doclib/action/file/site/{site}/{container}</url>
<url>/slingshot/doclib/action/file/site/{site}/{container}/{path}</url>
<format default="json">argument</format>
<authentication>user</authentication>
<transaction>required</transaction>
</webscript>

file.post.json.ftl file does not need to be changed, it imports the action.lib.ftl to create a standard results response json format.
file.post.json.js needs to be modified to handle a file instead of a folder.

The main changes I will make to this script:
  1. get a 'content' field from the json request object from the create-content form that we added previously.
     if (!json.isNull("content"))
    {
    content = json.get("content");
    }
  2. Rename variables folderName, folderDescription, folderTitle, folderPath to fileName, fileDescription, fileTitle, fileDescription and filePath respectively.
  3. Create a file node instead of a folder node
    var fileNode = parentNode.createFile(fileName);

  4. set the content on the newly created fileNode variable arfter the fileNode.save(); method
          fileNode.save();
    // Add uifacets aspect for the web client
    fileNode.content = content;
  5. Change messaging to reflect saving a file, not a folder.
The completed file.post.json.js looks like this:
<import resource="classpath:/alfresco/templates/webscripts/org/alfresco/slingshot/documentlibrary/action/action.lib.js">

/**
* Create file action
* @method POST
* @param uri {string} /{siteId}/{containerId}/{filepath}
* @param json.name {string} New file name
* @param json.title {string} Title metadata
* @param json.description {string} Description metadata
* @param json.content {string} Content of file
*/

/**
* Entrypoint required by action.lib.js
*
* @method runAction
* @param p_params {object} common parameters
* @return {object|null} object representation of action result
*/
function runAction(p_params)
{
var results;

try
{
// Mandatory: json.name
if (json.isNull("name"))
{
status.setCode(status.STATUS_BAD_REQUEST, "File name is a mandatory parameter.");
return;
}
var fileName = json.get("name");

var parentPath = p_params.path;
var filePath = parentPath + "/" + fileName;

// Check file doesn't already exist
var existsNode = getAssetNode(p_params.rootNode, filePath);
if (typeof existsNode == "object")
{
status.setCode(status.STATUS_BAD_REQUEST, "File '" + filePath + "' already exists.");
return;
}

// Check parent exists
var parentNode = getAssetNode(p_params.rootNode, parentPath);
if (typeof parentNode == "string")
{
status.setCode(status.STATUS_NOT_FOUND, "Parent folder '" + parentPath + "' not found.");
return;
}

// Title and description
var fileTitle = "";
var fileDescription = "";
if (!json.isNull("title"))
{
fileTitle = json.get("title");
}
if (!json.isNull("description"))
{
fileDescription = json.get("description");
}
if (!json.isNull("content"))
{
content = json.get("content");
}
// Create the folder and apply metadata
var fileNode = parentNode.createFile(fileName);
// Always add title & description, default icon
fileNode.properties["cm:title"] = fileTitle;
fileNode.properties["cm:description"] = fileDescription.substr(0, 100);
fileNode.properties["app:icon"] = "space-icon-default";
fileNode.save();
// Add uifacets aspect for the web client
fileNode.content = content;
fileNode.addAspect("app:uifacets");

// Construct the result object
results = [
{
id: filePath,
name: fileName,
parentPath: parentPath,
nodeRef: fileNode.nodeRef.toString(),
action: "createFile",
success: true
}];
}
catch(e)
{
status.setCode(status.STATUS_INTERNAL_SERVER_ERROR, e.toString());
return;
}

return results;
}

/* Bootstrap action script */
main();

This service uses the action.lib.js and action.lib.ftl libraries to simplify coding of similar functions, including: checkin, checkout, cancel-checkout, copy-to, move-to, file or folder delete and others. The action.lib.js is included in the folder.post.json.js script and provides a main() entry point to do common setup, clean up and host shared functions. The main entry point calls the runAction() function defined in the specific action script 'folder.post.json.js', effectively implementing a strategy pattern.

Step 3 - build and test
Since we have changed the Remote API source code, we must build alfresco.war and deploy to tomcat. I use the default ant task:
ant build-tomcat

to make sure all my changes are built. This will deploy share.war and alfresco.war.
Start up tomcat.
To view the new service, use the url http://localhost:8081/alfresco/service/index/uri/slingshot/doclib/action/file/site/%7Bsite%7D/%7Bcontainer%7D which should show the service correctly added.

Step 4 - alter 'New Content' button action to invoke new service
Now we have to use this new service we have created when we press the 'new content' button we added in the previous post. Change the toolbar.js onNewContent action line to read:
         var actionUrl = YAHOO.lang.substitute(Alfresco.constants.PROXY_URI + "slingshot/doclib/action/file/site/{site}/{container}/{path}",

this will call our new slingshot service to create a file, which in turn will call the new remote api service we added to alfresco.

Step 5 - deploy and test
Now, you should be able to press 'New Content' in share Document Library and create a new content item.