This page walks through the steps necessary to create an organization chart for customer contacts.
To assist with this walk-through, a generic Contact Management application can be downloaded. The primary purpose of the application is to instruct you on how to fully leverage the hierarchy browser tree component.
The following sections highlight different pieces of the application and explain how they work. After reading through this detailed explanation of the app, you should have a very good understanding of how the hierarchy browser tree works and how best to leverage its power.
To start, enter an application prefix, for example "CM" as an abbreviation of "Contact Management." Your prefix can be a maximum of 7 letters and numbers. Then, click "Download App".
After downloading the app, inspect and import the application on your development environment. If you have chosen the same prefix as someone before you, the app will fail the inspection, and you will need to pick a different prefix and re-download the app.
Once the application is on your environment, feel free to skip between sections based on what you want to learn. If you want to learn about the capabilities of the hierarchy browser tree in general, read through the whole page. If all you need to learn is how to build a navigation path from a single value, skip to that section. Before you skip to your section though, read through the application overview below to get a sense of how the application is structured.
The contact management application allows users to view and edit an organizational structure of contacts. The application consists of a single report to view an organizational structure and an action to manage it. The remaining objects all support these two interfaces.
All of your application objects will have a slightly different name than what is described below. This is because of your unique application prefix. The prefix allows everyone on your team to have their own sandbox app to learn with.
Before going into how the interfaces are made, let's look at the underlying data. The data type has three types of fields to note:
a!hierarchyBrowserFieldTree()
.All data is stored in a data store, called Customer Contact DS. The data store has a single entity that references the CustomerContact data type. The constant,DSE_CUSTOMER_CONTACT
, points to that entity for querying.
The data hierarchy has two main constraints: a contact can only have a single supervisor, and a contact can not be his or her own supervisor. Beyond that, the interface supports multiple contacts with no supervisor (meaning multiple roots to the hierarchy) and explicitly prevents circular relationships (for example, Greg's supervisor cannot be Allison if Allison's supervisor is Bob and Bob's supervisor is Greb). These complexities will give you the most possible experience with the component while preparing you for real-world design challenges.
Before continuing, make sure your Customer Contact DS data store is published and, once published, run the "Create Initial Data" process model. The data store must be published to use the interface and create the dummy data that makes understanding the interfaces below easier.
The primary pieces of the application are the manageCustomerContacts
and contactBrowserDashboard
interfaces.
The manageCustomerContacts
interface is the top-level interface for the "Manage Customer Contacts" action. It allows you to add, edit, and remove contacts.
The contactBrowserDashboard
is the top-level interface for the "Customer Contact Browser" report. It allows you to browse the hierarchy in a read-only state.
It has much of the same content as manageCustomerContacts
interface, minus controls to edit the data.
You may be wondering why the application uses an action to manage contact data rather than the report and Smart Services in interfaces. Here are the three primary reasons:
The "Manage Customer Contacts" process model backs the action to edit contact data. On clicking on the action, the user is taken to the contact management interface. From there, the user can go down 3 main paths:
After running smart services to delete, update, or create a contact, the process brings the user back to the management form.
All other objects in the app work to support the interfaces and process model that drive the application. When relevant to the goals of the sections below, these objects will be called out and explained.
Relevant objects: contactBrowserDashboard
, manageCustomerContacts
, getSelectedValue
Unlike in the Hierarchy Browser Columns, the hierarchy browser tree's selection is not passed separately to the component. The selected value is, instead, always the last value in the navigation path. In the image above, the navigation path is {"All Contacts", Jane Blake, Chris Smith}, where Jane Blake and Chris Smith are data type values. Because Chris Smith is the final value in the array, he is the selected value and the node is outlined in dark blue.
Throughout the application, the selected value is held in the targetContact variable. In the contactBrowserDashboard
interface, targetContact is a local variable. In the manageCustomerContacts
interface, targetContact is a rule input. In both interfaces, the variable is set in the hierarchy browser tree's pathSaveInto using the helper rule getSelectedValue
. getSelectedValue
takes the value at the last index of the navigation path array to get the component's selected value. You can see this in action in the snippet below, taken from contactBrowserDashboard
.
1
2
3
4
5
6
7
8
9
10
pathSaveInto: {
local!path,
with(
local!selectedValue: rule!getSelectedValue(local!path),
a!save(
{local!targetContact, local!foundContact},
if(rule!isCustomerContact(local!selectedValue), local!selectedValue, null)
)
)
}
As you can see, we first save to local!path
to preserve the user's navigation. If local!path
is not in the save, the component will not update when the user clicks on a node. Then, value selected is a CustomerContact, we save to local!targetContact
—the contact whose details are shown below the browser—and local!foundContact
—the contact shown in the contactFinder
picker. For explanation of why we need to check to see if the value is a CustomerContact, see the next section on Handling Multiple Roots.
Relevant objects: contactBrowserDashboard
, isCustomerContact
You may have noticed that the hierarchy browser tree must have a single value in its first level. This value is the "root" of hierarchy. This requirement can be problematic because not all hierarchies have a single root. In the case of customer contacts, it may be that there is no one person that all contacts report to or that the information on the root contact is unavailable or not useful. If that is the case, you are not stuck. There is a solution.
In the Contact Management application, this situation is handled by checking to see if there are multiple roots, and if there are, adding a text value at the start of the navigation path. This generates a single root value where there normally is not one. To see this in the interface configuration, first open up the contactBrowserDashboard
.
Notice first that local!path
is set using the following expression:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
If there are no contacts, make local!path null
If there is only one contact with no supervisor, he or she will be set as the root.
If there are multiple users with no supervisor, we create a placeholder root.
*/
local!path: if(
length(local!contactsWithNoSupervisor) = 0,
null,
if(
length(local!contactsWithNoSupervisor) = 1,
local!contactsWithNoSupervisor[1],
cons!ROOT_CONTACT_TEXT_READONLY
)
)
After adding this text value to the path, we need to make sure the hierarchy browser tree can handle that value. This handling of text can be seen in the pathSaveInto, nextLevelValues and nodeConfigs fields of the component. All three leverage the isCustomerContact
rule which returns true if the value is of type CustomerContact.
As shown in the section on Getting the Selected Value , the pathSaveInto first checks to see if the selected value is a contact before saving it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pathSaveInto: {
local!path,
with(
local!selectedValue: rule!getSelectedValue(local!path),
a!save(
{local!targetContact, local!foundContact},
/*
Checks to see if the selected value is a contact before saving it.
If could be a text-based root node for when multiple contacts have no supervisor
*/
if(rule!isCustomerContact(local!selectedValue), local!selectedValue, null)
)
)
}
Were we to save a text value into the local!foundContact
or local!targetContact
, it would break the other components that use those values.
The nextLevelValues also first checks to see if the fv!nodeValue
is a CustomerContact, then chooses the values to get accordingly.
1
2
3
4
5
6
7
8
9
10
/*
If the value is a contact, get subordinates.
If it is not a contact, it is the placeholder root value we created in the path.
The values in the next level are thus the contacts with no supervisor
*/
nextLevelValues: if(
rule!isCustomerContact(fv!nodeValue),
rule!getSubordinatesFromCustomerContact(fv!nodeValue),
local!contactsWithNoSupervisor
)
Finally, the nodeConfigs input handles the synthesized root value with another check for a CustomerContact v.s. Text value. The check should look very familiar.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
If the value is a contact, use the customer contact node configs.
If it is not a contact, it is the placeholder root value we created in the path.
It is a text value, so we use the rule that turns a text value into a node.
*/
nodeConfigs: if(
rule!isCustomerContact(fv!nodeValue),
rule!customerContactNodeConfigs(fv!nodeValue),
rule!textBasedContactBrowserNode(
text: cons!ROOT_CONTACT_TEXT_READONLY,
icon: a!iconNewsEvent(icon: "PEOPLE_3"),
isDrillable: true
)
)
Relevant objects: getContactById
, buildContactHierarchyPath
, buildContactHierarchyPath_helper
, contactFinder
One of the main benefits of the hierarchy browser tree is the context it provides. When looking at the contact browser, you can see who reports to a contact and who the contact reports to. You may have noticed, however, that simply providing the contact as the path leaves out that second part; your user will not see who the contact reports to. One option would be to always initialize the component at the root and let the user browse to the contact they want to see. The much better option is to dynamically generate the navigation path that shows the hierarchy from the root to the selected contact without having the user manually browse there. This section shows you how to do that.
The first thing you need in any case where you want to build the navigation path is a rule that, given some value, returns the value above it in the hierarchy. In the contact management app, that means we need a rule that, given a contact, returns the contact's supervisor. Because we have the supervisor's ID in the contact data type, we really just need a rule that can get a contact from its ID. In our app, that rule is called getContactById
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if(
isnull(ri!id),
null,
with(
local!results: a!queryEntity(
entity: cons!DSE_CUSTOMER_CONTACT,
query: a!query(
filter: a!queryFilter("id", "=", ri!id),
pagingInfo: a!pagingInfo(startIndex: 1, batchSize: 1)
)
),
cast(
'type!{urn:com:appian:recipes}CustomerContact',
if(length(local!results) = 0, null, local!results.data)
)
)
)
Next, we need a way to repeatedly call our rule and take the result of the prior evaluation as an input. For calling functions repeatedly, you use Looping Functions. The most commonly used looping function is apply(), but the problem with apply is that each evaluation of the specified rule is ignorant of the other evaluations. Having knowledge of the prior evaluations result is a key facet to the problem. If Sarah reports to Chris, we can easily query for Chris, generate a path of {Chris, Sarah}, and display the tree below.
Things get harder, though, when Chris has a supervisor, and Chris' supervisor has a supervisor, and so on and so forth. We don't know how many people we have to go through until we reach the top, and we don't know the next supervisor to query for until we have the information about a subordinate. What we need is reduce(). With reduce(), we can loop over our query function and return the result to the next evaluation. That way, we can query for Chris, then query again for Chris' supervisor, Jane, then keep going until we find a contact without a supervisor to query for.
The rule that uses reduce() is buildContactHierarchyPath
.
1
2
3
4
5
6
7
8
9
10
/* Has a 50 contact limit */
if(
isnull(ri!contact),
{},
reduce(
rule!buildContactHierarchyPath_helper,
ri!contact,
enumerate(50)
)
)
A few things to point out:
buildContactHierarchyPath_helper
, that calls getContactById
reducing over getContactById
would only result in the root contact, not the whole path. We need a rule that can keep track of and preserve our list of results that becomes our path.buildContactHierarchyPath_helper
, there needs to be a 'dummy' input for the result of enumerate(). It won't be used, but reduce()
requires it.Below is the definition of buildContactHierarchyPath_helper
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
with(
/*
We use the first value because we are always appending the new value
to the start of the path so that supervisors come before subordinates
in the path array.
*/
local!lastResult: ri!path[1],
if(
or(
/* If there isn't more to query */
isnull(local!lastResult.supervisorId),
/* Or, if we've hit a circular reference, stop querying */
not(exact(union(ri!path.id, tointeger({})), ri!path.id))
),
ri!path,
/* Otherwise, get the next supervisor to add to the path */
{
rule!getContactById(local!lastResult.supervisorId),
ri!path
}
)
)
First, notice that it is always getting the first value in the array.
1
local!result: ri!path[1]
We use the first value because we are always appending the new value to the start of the path so that supervisors come before subordinates in the path array. This can be seen at the end of the rule where we're putting the result of getContactById
ahead of ri!path
.
1
2
3
4
{
rule!getContactById(local!lastResult.supervisorId),
ri!path
}
Next, notice that we are checking to see if the local!lastResult
has a null supervisorId. If it does, we return the ri!path
that was passed in because we know we've reached a root.
1
2
3
4
5
6
7
8
9
if(
or(
/* If there isn't more to query */
isnull(local!lastResult.supervisorId),
...
),
ri!path,
...
)
Last, notice that we make another check in the if()
above to ensure that we have only unique values in the array.
1
2
/* Or, if we've hit a circular reference, stop querying */
not(exact(union(ri!path.id, tointeger({})), ri!path.id))
This check is used to account for circular references. In the next section, you'll learn more about this.
To see the path construction in action, open up the contactFinder
interface. In the preview search for a contact, select the contact, and watch as ri!path
is updated.
Relevant objects: contactInformationSection
, buildContactHierarchyPath
, buildContactHierarchyPath_helper
In the Contact Management application, a circular reference is when two people, through any number of levels in the hierarchy, can be considered each others' supervisor. In the example below, Jane Blake, Sarah Hernandez, and Chris Smith are in a circular hierarchy. Jane Blake's supervisor is Sarah Hernandez; Sarah Hernandez's supervisor is Chris Smith; and Chris Smith's supervisor is Jane Blake.
In the previous section, we discussed building a navigation path from a single value. The rule we talked about, buildContactHierarchyPath
, is the key to preventing circular references.
To see how we do it, open up the contactInformationSection
interface. In the contactInformationSection
interface, go to the custom picker with the label "Supervisor". This is where contact supervisors can be updated and we run the risk of a user creating a circular reference. To ensure that a user does not create a circular reference, we build the to-be path on setting the prospective supervisor. In building the path, we do not want to repeatedly query for supervisors if the user is trying to create a circular reference. The user experience would suffer. That is why in our buildContactHierarchyPath_helper
, we check for duplicate contacts in the path which indicates a circular reference.
If we do not find a circular reference, the user can continue making updates. To improve performance, we can also use the path we created on the supervisor change when the user submits the form and is taken to the new location of the contact.
If we do find a circular reference, we want to trigger a validation that tells the user what is wrong and prevents the form from being submitted. In contactInformationSection
, the validation is triggered by a local variable, local!isCircularReference
.
1
2
3
4
5
6
7
8
9
10
11
12
/*
Used for preventing circular references.
Checks to see whether a new path after a supervisor update
would result in a hierarchy that contains the target contact
more than once
*/
local!isCircularReference: length(
wherecontains(
index(ri!contact, "id", tointeger(null)),
tointeger(index(ri!path, "id", {}))
)
) > 1,
This local variable checks to see if the path contains the target contact more than once. If it does, we know that the user is trying to create a circular reference. So, in the custom picker, we show a validation error.
1
2
3
4
5
6
7
8
9
validations: if(
...
if(
local!isCircularReference,
"Two contacts cannot be each other's supervisor",
""
)
...
)
With that validation, circular references are prevented.
Relevant objects: Manage Customer Contacts
, manageCustomerContacts
, contactInformationSection
, cons!ADD_ROOT_CONTACT_TEXT
, cons!ADD_CONTACT_TEXT
As stated in the overview, we cannot use Smart Services in interfaces to make updates. Instead, we need to use a process. The process model responsible for adding, updating, and removing nodes is called Manage Customer Contacts
. Looking at the model, you can see that there are two paths: one for adding and updating and one for deleting. Right now, we'll focus on the add case. Within the add case, there are two paths: one for adding a root and one for adding a leaf. More generally, the case for adding a root is really the case for adding any value with a level in the hierarchy below it. The idea is that you need to update both the new contact and all existing subordinates that will report to the new contact.
The interface that populates t the values to update is the manageCustomerContacts
interface. There are a few important things to explain in the interface before we dive deeper into the process model.
Open the manageCustomerContacts
interface. First, notice that "Add New Contact" nodes are used instead of buttons to allow the user to intuitively add new contacts wherever they want. This style of contact management allows users add three categories of contacts:
When a user clicks on a node to add a new contact, a few things happen in the save. See the comments in the interface snippet below for a detailed description of what is happening.
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
44
45
46
47
48
49
50
51
pathSaveInto: if(
/*
1. If the user has started editing a new contact or updating an existing contact,
we block navigation by setting the pathSaveInto to null
*/
or(local!workStarted, local!actionTaken),
null,
/*
2. If work is NOT in progress, always save the path. We need to save the path
for the component to respond to a user's click.
*/
{
local!path,
/*
3. The add new root is dropped from local!path and the result is saved to ri!path.
ri!path is a CustomerContact list that cannot hold the text value of a new root contact node
*/
a!save(ri!path, ldrop(local!path, 1)),
with(
local!selectedNode: rule!getSelectedValue(local!path),
/*
If a contact is clicked
save the selected node as to the "targetContact" and "foundContact"
Otherwise, a new contact node is clicked. Therefore:
Set the new contacts supervisorId to the value above it in the path
*/
if(
rule!isCustomerContact(value: local!selectedNode),
...,
/*
4. `local!newContact.supervisorId` is updated with what will be the new node'ssupervisor.
We update the `local!newContact.supervisorId` so that the new contact form below
the hierarchy browser tree shows the correct supervisor
*/
{
a!save(
local!newContact.supervisorId,
rule!multiIndex(local!path, {length({local!path}) - 1, "id"}, null)
),
/*
5. Drop add new node from the path.
ri!path is a CustomerConact list and cannot hold the text value of a new contact node.
*/
a!save(ri!path, rdrop(local!path, 1)),
a!save({ri!targetContact, local!foundContact}, null),
a!save(ri!newOrUpdatedImage, null)
}
)
)
}
)
Now that the user has clicked a new contact node, the variable local!selectedNode
will be a text value (either cons!ADD_ROOT_CONTACT_TEXT
or cons!ADD_CONTACT_TEXT
). Because local!selectedNode
is Text, local!newContactSelected
will be true
. Since local!newContactSelected
is true
, the bottons to update or delete a contact disappear.
1
2
3
4
5
6
7
8
9
if(
or(
local!newContactSelected, /* Or set to true here */
local!workStarted,
local!actionTaken
),
{},
a!buttonLayout(...)
)
and the contactInformationSection
will display along with buttons to create a new contact or cancel the update.
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
44
45
46
47
if(
local!newContactSelected,
{
rule!contactInformationSection(
contact: local!newContact,
label: cons!ADD_CONTACT_TEXT,
newOrUpdatedImage: ri!newOrUpdatedImage,
path: ri!path
),
a!buttonLayout(
primaryButtons: {
a!buttonWidget(
label: "Create New Contact",
style: "PRIMARY",
value: local!newContact,
saveInto: {
ri!targetContact,
a!save(ri!goTo, cons!CUSTOMER_CONTACT_ACTIONS[1]),
a!save(ri!rootContactAdded, local!selectedNode = cons!ADD_ROOT_CONTACT_TEXT)
},
submit: true
),
a!buttonWidget(
label: "Cancel New Contact",
disabled: not(local!workStarted),
value: null,
saveInto: {
a!save(local!newContact, 'type!{urn:com:appian:recipes}CustomerContact'()),
/* Remove the "Add New" from the path when they hit cancel if it's not the root */
if(local!pathLength > 1, a!save(local!path, rdrop(local!path, 1)), null),
a!save(ri!path, rdrop({ri!path}, 1)),
if(
rule!isCustomerContact(rule!getSelectedValue(local!path)),
a!save(ri!targetContact, rule!getSelectedValue(local!path)),
null
),
a!save(
ri!newOrUpdatedImage,
index(ri!targetContact, "imageId", null)
)
}
)
}
)
},
...
)
Once the user edits any information in the contactInformationSection
, the variable local!workStarted
will be set to true.
1
2
3
4
5
6
7
8
9
local!workStarted: if(
local!newContactSelected,
/*
Checks to see if the user has added any values. The user does not set the supervisor,
so our check needs to ignore the supervisorId.
*/
local!newContact <> 'type!{urn:com:appian:recipes}CustomerContact'(supervisorId: local!newContact.supervisorId),
...
)
Remember from earlier that if local!workStarted
is true
, navigation will be disabled. This means that the user is locked into the new node until they click "Cancel New Contact".
After entering information, when the user clicks "Create New Contact":
local!newContact
is saved to ri!targetContact
ri!goTo
is updated appropriately to route the proces flowri!rootContactAdded
is set if the selected node is the root contact text1
2
3
4
5
6
7
8
9
10
11
a!buttonWidget(
label: "Create New Contact",
style: "PRIMARY",
value: local!newContact,
saveInto: {
ri!targetContact,
a!save(ri!goTo, cons!CUSTOMER_CONTACT_ACTIONS[1]),
a!save(ri!rootContactAdded, local!selectedNode = cons!ADD_ROOT_CONTACT_TEXT)
},
submit: true
)
Once in process, the flow is routed down the update/create flow:
pv!targetCotact.lastUpdated
is set to now()
pv!targetCotact.imageId
is updated with the uploaded image if one is present. We need to update this value in process because the document ID is not set until the form is submitted.supervisorId
Relevant objects: manageCustomerContacts
, Manage Customer Contacts
When a user clicks on a contact node in the manageCustomerContacts
interface, local!newContactSelected
is set to false
. As a rules, the user has the option to click "Delete Contact". A few things are saved when clicking "Delete Contact".
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
a!buttonWidget(
label: "Delete Contact",
value: cons!CUSTOMER_CONTACT_ACTIONS[2],
saveInto: {
/* 1. `ri!goTo` is updated to take the process flow through the correct path */
ri!goTo,
/*
2. The root node is removed from the path and the deleted value is removed,
allowing us to bring the user back to the supervisor of the deleted contact.
*/
a!save(ri!newOrUpdatedImage, null),
/* 3. The root and target contact are removed from the path */
a!save(ri!path, rdrop(ldrop(local!path, 1), 1))
},
submit: true
)
The Manage Customer Contacts
process then handles the deletion of the contact.
Relevant objects: manageCustomerContacts
, contactInformationSection
, Manage Customer Contacts
We'll start the explaination in the interface, so open manageCustomerContacts
. To update a contact, the user needs to click on a contact node in the management interface. When a user clicks on a contact in the manageCustomerContacts
interface, they have the option to click the "Update Contact" button.
On clicking "Update Contact", ri!goTo
is updated.
1
2
3
4
5
a!buttonWidget(
label: "Update Contact",
value: cons!CUSTOMER_CONTACT_ACTIONS[1],
saveInto: ri!goTo
)
Once ri!goTo
is set, local!actionTaken
equals true
.
1
local!actionTaken: not(isnull(ri!goTo))
Since local!actionTaken
is true
and local!newContactSelected
is false
, the contactInformationSection
is populated with the selected contact and becomes editable. In addition, buttons appear below the section that allow the user to cancel or confirm the contact edits.
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
if(
local!newContactSelected,
...,
{
rule!contactInformationSection(
contact: ri!targetContact,
isDisabled: not(local!actionTaken),
label: "Update " & rule!displayContactFullName(ri!targetContact),
newOrUpdatedImage: ri!newOrUpdatedImage,
path: ri!path
),
if(
not(local!actionTaken),
{},
a!buttonLayout(
primaryButtons: {
a!buttonWidget(
label: "Confirm Update",
style: "PRIMARY",
submit: true
),
a!buttonWidget(
label: "Cancel Update",
value: null,
saveInto: {
ri!goTo,
a!save(ri!newOrUpdatedImage, ri!targetContact.imageId),
a!save(ri!targetContact, local!selectedNode)
}
)
}
)
)
}
)
The user can freely update the contact's fields. Open up contactInformationSection
. In general, when fields are not disabled, user inputs are stored directly into fields in ri!targetContact
. The two exceptions are the file upload for the picture of the contact and the custom picker for the contact's supervisor.
1
2
3
4
5
6
7
8
9
10
11
if(
or(ri!isDisabled, ri!isReadOnly),
...,
a!fileUploadField(
label: "Image",
target: cons!CUSTOMER_CONTACT_IMAGE_FOLDER,
value: ri!newOrUpdatedImage,
saveInto: ri!newOrUpdatedImage,
disabled: ri!isDisabled
)
)
We need a document type, rather than an integer field in a data type, to hold the image being uploaded because the file upload field requires it. In process, the contact's imageId field will be updated accordingly.
If the supervisor is updated, we need check for circular references and update ri!path
appropriately. See the linked section for more information on the check for circular references.
After having made updates, the user can choose to submit the changes by clicking "Confirm Update."
1
2
3
4
5
a!buttonWidget(
label: "Confirm Update",
style: "PRIMARY",
submit: true
)
Once in the Manage Customer Contacts
process, the flow follows a similar path to adding a new contact.
pv!targetCotact.lastUpdated
is set to now()
pv!targetCotact.imageId
is updated with the uploaded image if one is present. We need to update this value in process because the document ID is not set until the form is submitted.