Part XVIII – Working with Workflows in OpenText Content Server

In Part I of this blog series I introduced an object-based approach for developing with OpenText Content Server. In this next blog post I extend the discussion to include workflows.

The Content Server Workflow API is complex. There is little abstraction or encapsulation, which means operations often require the traversal of complex data structures, converting between workflow representations (e.g., workID, subWorkID, WAPIWork, workData, etc.), and knowledge of where and what functions are available to operate on it. It's not obvious how it works, and usually requires a considerable amount of reverse engineering to develop with it.

For example, consider the following Workflow GeneralCallbackScript to manipulate a workflow attribute value:

function Dynamic MyGeneralCallbackScript( \
		Object		prgCtx, \
		WAPIWORK	work, \
		Integer		workID, \
		Integer		subWorkID, \
		Integer		taskID, \
		Integer		returnSubWorkID, \
		Integer		returnTaskID, \
		Dynamic		extraData = Undefined )

	// Get the workData for this workflow
	RecArray workData = $WFMain.WAPIPkg.LoadWorkData(prgCtx, work)

	// Fetch the task record for the task id, which we'll need later
	Record task = prgCtx.WSession().LoadTaskStatus(workID, subWorkId, taskID)[1]

	Record workItem
	Boolean found = false

	// Get the package for the workflow attributes
	Object obj = $WFMain.WFPackageSubsystem.GetItemByName('WFAttributes')

	if IsDefined(obj)
		// Iterate the workData and find the workItem for the workflow attributes
		for workItem in workData
			if {workItem.TYPE, workItem.SUBTYPE} == {obj.fType, obj.fSubType}
				found = true
				break
			end
		end
	end

	// If we found the attribute workItem then we can manipulate the attribute value
	if found
		// Here we'd modify the attribute value by traversing the workItem structure (which is highly error prone)
		// Simplified to one line for brevity
		workItem.USERDATA.Content.RootSet.ValueTemplate.Values[1].(2).Values = {'My New Value'}
	end

	// Save the changes.
	return $WFMain.WAPIPkg.SaveWork(prgCtx, task, workData, work)

end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

This pattern is found often when operating on a workflow. It requires knowledge of various functions, understanding of the package and workData data structures, and knowledge of how these structures are related in order to extract the data for that package. The package data has its own structure, which you must also understand in order to do something with it.

I believe much of this can be abstracted and made easier for the developer. Let's see how RHCore does this.

# Introducing RHWorkStatus & RHWorkStatusTask

RHCore introduces the RHWorkStatus and RHWorkStatusTask classes to programatically manipulate a workflow. The classes encapsulate the data structures behind a workflow, while abstracting the programming interface into something that is easier to use.

An instance of RHWorkStatus can be created by calling:

Frame wf = $RHCore.RHWorkStatus.NewFromWorkID(prgCtx, workID, subWorkID)
1

The RHWorkStatus instance abstracts away many of the patterns you typically see when dealing with workflows, namely:

  • fetching and manipulating package data (comments, attributes, attachments, audit, etc.);
  • fetching metadata (including calculated values) about the workflow;
  • allocating and deallocating the WAPIWork instance;
  • fetching task (or "step") data;
  • applying an action (accept, complete, or reassignment of a task, set the workflow status, etc.);
  • and more...

The classes also encapsulate the data structure behind the workflow, which means you don't need to traverse anything to get to the data of interest. This provides fail-safes and lowers the risk of an error.

An RHWorkStatus instance provides a number of methods to operate on and fetch information about the workflow. A few examples:

// Get the map node as an RHNode
Frame mapNode = wf.mapNode()

// Get the status colour of the workflow
String statusColour = wf.statusColour()

// Get the due date of the workflow
Date duedate = wf.due()

// Get the URL to open this workflow
String url = wf.url()

// Get the attribute data of the workflow as an instance of RHAttrData
Frame attrdata = wf.attrdata()

// Get the attachments folder as an RHNode
Frame attachments = wf.attachmentsfolder()

// Get the workflow manager as an RHUser
Frame manager = wf.manager()

// Change the status of the workflow (valid values include
// WAPI.WORK_STATUS_SUSPENDED,  WAPI.WORK_STATUS_EXECUTING, WAPI.WORK_STATUS_STOPPED,
// WAPI.WORK_STATUS_ARCHIVED, & $WFMain.WFConst.kWFDelete)
wf.setStatus(newStatus)

// get all current tasks
Frame currentTasks = wf.tasks().filter("isCurrent", "==", true)

// get all performer tasks that are currently active
Frame currentTasks = wf.tasks() \
		.filter("isCurrent", "==", true) \
		.filter("isPerformerTask", "==", true)

// Get the task with ID 1 (as a RHWorkflowStatusTask instance)
Frame task = wf.tasks(1)

// Save any changes back to the workflow (with the current task as a parameter)
Assoc results = wf.save(task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

A RHWorkflowStatusTask instance represents a workflow task (or "step") and also provides a number of useful methods:

// Is this task active and current?
Boolean taskIsCurrent = task.isCurrent()

// Is the task completed?
Boolean taskIsDone = task.isDone()

// Get the instructions for the task
String instructions = task.instructions()

// Get the performer of the task as an RHUser
Frame performer = task.performer()

// Get the display name of the performer
String performerDisplayName = task.valueForKeyPath('performer.displayName')

// Does this step represent a sub-workflow?
Boolean isSubWorkflow = task.isSubMapTask()

// When was this task assigned?
Date dateAssigned = task.dateAssigned()

// Get the status in a human readable form (e.g., "Current", "Not Used", "Completed", "Waiting")
String status = task.statusVerbose()

// Is the task an unassigned performer task?  If not, assign it to the current user
if task.isPerformerTask() and NOT task.isTaskAssigned()
	results = task.acceptTask()
end

// Complete a workflow task
Assoc results = task.complete()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

These are just a few examples, but provides an idea of how it works.

# Examples

Let's revisit the example from the introduction and rewrite it using the RHWorkStatus class:

function Dynamic MyGeneralCallbackScript( \
		Object		prgCtx, \
		WAPIWORK	work, \
		Integer		workID, \
		Integer		subWorkID, \
		Integer		taskID, \
		Integer		returnSubWorkID, \
		Integer		returnTaskID, \
		Dynamic		extraData = Undefined )

	// Create an instance of RHWorkStatus
	Frame wf = $RHCore.RHWorkStatus.NewFromWorkID(prgCtx, workID, subWorkID)

	// Get the current task as RHWorkStatusTask (required later)
	Frame task = wf.tasks(taskID)

	// Get the attribute frame, which is an instance of RHAttrData
	Frame attrdata = wf.attrdata()

	if IsDefined(attrdata)
		// Use the setter on RHAttrData to modify the attribute value
		attrdata.SetValue(wf.mapobjid(), 2, "My New Value")
	end

	// Save the changes.
	return wf.save(task)

end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

I find this faster to develop, easier to read, and less error-prone than the standard approach.

# A note about RHAttrData

While standard categories and attributes differ from workflow attributes, there are enough similarities for the APIs to overlap. The wf.attrdata() call returns an instance of RHAttrData, which provides a rich API for setting and getting attribute values. See Part VI – Developing with Categories & Attributes in OpenText Content Server for more information.

# Example with attachments

Let's look at another example using workflow attachments. Say your workflow modifies a set of documents, and you wish to copy the latest document version from the workflow back to the original document. This could be done with the following event script:

function Dynamic MyGeneralCallbackScript( \
		Object		prgCtx, \
		WAPIWORK	work, \
		Integer		workID, \
		Integer		subWorkID, \
		Integer		taskID, \
		Integer		returnSubWorkID, \
		Integer		returnTaskID, \
		Dynamic		extraData = Undefined )

	// Create an instance of RHWorkStatus
	Frame wf = $RHCore.RHWorkStatus.NewFromWorkID(prgCtx, workID, subWorkID)

	// Get the attachments folder as an RHNode
	Frame attachmentsFolder = wf.attachmentsFolder()

	// Get the contents of the attachments folder and filter by document
	Frame children = attachmentsFolder.children().filter('subtype', '==', $TypeDocument)

	// Some variables for the iteration
	Frame child, originalNode

	// Iterate and copy the last version from the WF copy back to the original document
	while IsDefined(child=children.next())

		// Get the original node of the workflow copy
		originalNode = child.wforiginalnode()

		if IsDefined(originalnode)
			// Add the latest document version to the original
			originalNode.addVersion(child)
		end
	end

	// Skip error handling for brevity
	return true

end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

This example is as much a demonstration of RHNode as RHWorkStatus, but shows how the two can work together to perform basic document management tasks in the context of a workflow. Just consider how many lines of code the equivalent would have taken without these classes.

# Instantiating a Workflow

Workflows can also be instantiated with RHCore. For this we use the RHWorkflowMap class, which provides methods to setup the workflow before instantiating. The constructor is as follow:

// First, get the RHNode representation of the map node
Frame node = $RHCore.RHNode.New(prgCtx, <DataID, nickname, nodeRec, DAPINode, or RHNode>)

// Second, get an instance of RHWorkflowMap by calling wfmap()
Frame wfmap = node.wfmap()
1
2
3
4
5

A number of methods are available on the RHWorkflowMap instance to setup the workflow:

//  Set the title of the workflow
wfmap.setName("My Demo Workflow")

// Add an attachment to the workflow (can be called multiple times)
wfmap.addAttachment(<DataID, nickname, DAPINODE, nodeRec, or RHNode>)

// Get an RHAttrData frame to manipulate the attributes
Frame attrdata = wfmap.attrdata()
attrData.SetValue(wfmap.mapid(), 2, "My initial attribute value")
1
2
3
4
5
6
7
8
9

Once the instance is setup we can initiate the workflow with the start() method:

Assoc results = wfmap.start()
1

# Querying Workflows

RHCore provides an abstraction to query workflows. This is the programatic equivalent to the func=work.workflows request handler, which is found under the Personal menu in Content Server.

A workflow query in RHCore can be constructed as follows:

Frame wfs = $RHCore.RHWorkflowQuery.New(prgCtx)
1

The constructor defaults to all non archived workflows sorted by name that the user has access to. Some defaults can be changed:

// Return only managed workflows
wfs.setKind("managed")

// Return only archived workflows
wfs.setStatus("archived")
1
2
3
4
5

The RHWorkflowQuery instance is a subclass of RHTableQuery (see Part XVII – Table Queries in OpenText Content Server), which allows the results to be paged, sorted, and filtered. However, the page size cannot be changed as it is controlled by the user's personal workflow settings (more on this later).

// sort by start date in descending order
// valid values include "title", "due", "relationship", "start", "status"
wfs.sort('-start')

// set the page number to 5
wfs.setPageNumber(5)
1
2
3
4
5
6

Once we have setup the query we call the iterator() method to return the result set as an Iterator object. As with many RHCore objects, these calls can be chained into a single expression:

Frame iter = $RHCore.RHWorkflowQuery.New(prgCtx) \
	.setKind('managed') \
	.setStatus('archived') \
	.sort('-sort') \
	.setPageNumber(5) \
	.iterator()
1
2
3
4
5
6

The result can then be iterated to perform batch operations or to display in a web page:

Frame wf

while IsDefined(wf=iter.next())
	// do something with wf
	// wf is an instance of RHWorkStatus
end
1
2
3
4
5
6

# Limitations

Although RHWorkflowQuery is a RHTableQuery subclass, behind the scenes the query is still executed using the same workflow query functions used by Content Server (i.e., the same code as the func=work.workflows request handler). This comes with a few limitations.

The primary limitation is performance. Content Server workflow queries do not scale. The operation works by fetching all workflows that match the query, iterating over each record to perform some calculations, sorting in memory, and only then slicing the result set for paging. I've seen the workflow page take minutes to load on systems with thousands of active workflows. Sorting or paging means having to wait minutes again for the page to reload. It's unusable.

The second limitation is that paging is forced and the page size cannot be changed. This might cause problems depending on what the query is being used for.

RHCore addresses these limitations by providing a second class for querying workflows called RHWorkflowQuery2. The interface is identical to RHWorkflowQuery, but all paging, sorting, and filtering is applied at the database level by leveraging the features of RHTableQuery. This also has some limitations:

  • sorting and filtering by due date is not possible since it's a calculated value; and
  • the indentation rules for sub-workflows (i.e., how sub-worklows are indented in the func=work.workflows) page are not calculated.

Despite these minor limitations, the boost in performance and paging control makes it useful in some situations.

# Filtering

RHWorkflowQuery and RHWorkflowQuery2 queries can be filtered using the filter() method. Filters work at the database level for optimal performance. For example, to return all workflows initiated by cmeyer in the last 14 days:

Frame user = $RHCore.RHUser.New(prgCtx, 'cmeyer')

Frame wfs = $RHCore.RHWorkflowQuery.New(prgCtx) \
		.filter('SUBWORK_DATEINITIATED', '=>', $RHCore.DateUtils.AddDays(Date.Now(), -14)) \
		.filter('WORK_OWNERID', '==', user)
1
2
3
4
5

A filterAttribute() method is also available to filter on workflow attribute values. The method accepts a map ID, attribute ID, operator, and value. The method takes these values and transparently extends the underlying query to join with the WFAttrData table. For example, say you have a workflow map with a "Price" attribute. You now wish to find all workflow instances where "Price" is at least 500:

Integer MapID = ...
Integer AttrID = ... // ID of "Price" attribute
Integer minimumPrice = 500

Frame wfs = $RHCore.RHWorkflowQuery2.New(prgCtx) \
	.filterAttribute(MapID, AttrID, '=>', minimumPrice)
1
2
3
4
5
6

For more complex queries you can use the extra() method, which is detailed in the Part XVII – Table Queries in OpenText Content Server.

# Wrapping Up

Workflows are large and complex and this blog post scratches the surface of what RHCore can do. The extension is still a work in progress, and I eventually plan to add support for other workflow steps such as Forms and eSign. However, with this foundation I don't anticipate it being difficult to do.

What type of difficulties have you had developing around workflows? I welcome your questions and comments below.