How a custom objects app is built for Sell
Sometimes it can be difficult to represent all of your business context using just Leads, Contacts, and Deals in Zendesk Sell. However, with custom objects you can create new object types to modify your Sell data model to fit your needs.
Once your custom data model is defined, you can surface it in leads, contacts, and deal cards in Sell to provide context for the user. To demonstrate how this can be done, an example custom objects app is available to download and use.
This article describes how the app is implemented along with information to set up and experiment with the app. Related information:
Overview
The example custom objects app appears on the Zendesk Sell Deal page and manages invoice records assigned to a Sell Deal object.
There is a one-to-one relationship between the Sell Deal object and the invoice custom object record. It uses the Custom Objects API to interact with the custom objects resources management.
When a user opens a deal card in Sell, the app makes a HTTP GET request to the Custom Objects API to fetch the invoice associated with it. If the record exists, it is displayed in the app with options to edit or delete the record. If an invoice related to the deal doesn't exist, a button is displayed to create a new invoice.
Requirements
To upload and install a private app in Sell, you must have the following:
- Zendesk Sell on the Team plan or above
- A Zendesk Suite plan to use custom objects
If you're interested in becoming a Zendesk developer partner, you can convert a trial account into a sponsored Zendesk Support account. See Getting a trial or sponsored account for development.
Getting started
To use the app, complete the tasks as described in the following sections.
Downloading the app template
You can review the app’s source code at https://github.com/zendesk/sell-custom-objects-invoices-app. The app is built on the Zendesk React app scaffold. It allows you to bootstrap a React-based application which is integrated with the Zendesk Apps framework (ZAF). It is intended for experienced web developers who are comfortable working with advanced web tooling such as Webpack, Node, and npm packages, among other technologies.
Disclaimer: Zendesk can't provide support for third-party technologies such as Webpack, Node.js, or npm packages, nor can Zendesk debug custom scaffold configurations or code.
Download the app template
-
Go to https://github.com/zendesk/sell-custom-objects-invoices-app and select Code > Download ZIP to your local machine and unzip the file.
-
In your command line tool, run the following command to install the necessary packages:
$ npm install
$ npm install node-fetch
Enabling custom objects
Custom objects must be enabled by an administrator in Zendesk Support. If you're not an admin, ask one to enable them for you. For more information, see Enabling custom objects.
Installing ZCLI
The Zendesk Command Line Interface (ZCLI) is a command-line tool for creating the necessary app files, testing, validating, and packaging your app. To install it, follow the instructions in Installing and updating ZCLI.
Creating the custom object schema
To quickly get up and running, a script is provided to create the schema for the custom object. The custom object type is an invoice and there is a one-to-one relationship between the Sell deal object (zen:deal
) and the invoice.
Create the schema
- Open the custom_objects_schema_setup.js file in your text editor.
- Provide details for the following properties:
ACCESS_TOKEN
- API tokens are managed in the Admin Center interface at Apps and integrations > APIs > Zendesk API. If needed, create a new API token and paste it in the script. Note: Be careful to not publicly expose your token.MAIL
- Your account email addressSUBDOMAIN
- Your Zendesk Sell subdomain
- From the project root directory, run
$ node custom_objects_schema_setup.js
. - Review the created object type and relationship in Admin Center > Sunshine > Objects and Admin Center > Sunshine > Relationships.
Installing the app
See Uploading and installing a private app in Zendesk Sell for information on installing a private app in Zendesk Sell.
You should see the app in Sell when viewing a Deal card.
Implementation details
The following sections describe the implementation of CRUD operations using the Custom Objects API in the app.
Getting data
In sell-custom-objects-app-tutorial > src, the index.tsx file contains a return
method.
return (
...
<Router>
<Switch>
<Route exact path="/new" component={NewView} />
<Route exact path="/edit" component={EditEntryView} />
<Route exact path="/delete" component={DeleteView} />
<Route component={EntryView} />
</Switch>
</Router>
...
)
In the Router
section, "EntryView" is defined as the default path. src/EntryView.tsx is a component which makes an HTTP request and then displays the data. All ...View.tsx files are responsible for gathering the data and HTTP requests.
export const EntryView = () => {
useClientHeight(215);
const dealIdResponse = useClientGet("deal.id");
return (
<Grid gutters={false} className={css.App}>
<Row>
<ResponseHandler
response={dealIdResponse}
loadingView={<Loader />}
errorView={<div>Something went wrong!</div>}
emptyView={<div>There is no Deal</div>}
>
{([dealId]: [string]) => <DetailsView dealId={dealId} />}
</ResponseHandler>
</Row>
</Grid>
);
};
The first request is in the useClientGet hook. It uses the client.get()
method to retrieve a deal based on a current location. That is, it calls client.get('deal.id')
for the location.
useClientHeight is another hook that is useful when you need to manage an app's height. It accepts a height value and calls client.invoke('resize' , {height})
.
The <ResponseHandler/>
component is responsible for handling asynchronous requests. Depending on a request status it can display a loader, an error state, or an empty state. When the request has finished successfully, a child component with the response data will be rendered.
At this point, there's a deal.id
which can be passed to the DetailsView
component. Open src/components/DetailsViews.tsx.
const DetailsView = ({ dealId }: { dealId: string }) => {
const history = useHistory();
const sunshineResponse = useClientRequest(
`/api/sunshine/objects/records/zen:deal:${dealId}/related/deal_invoice`
);
const handleEdit = useCallback(() => history.push("/edit"), []);
const handleDelete = useCallback(() => history.push("/delete"), []);
const isInvoiceListEmpty = (response: { data: InvoiceListResponse }) =>
response.data.data.length === 0;
return (
<ResponseHandler
response={sunshineResponse}
loadingView={<Loader />}
errorView={<div>Something went wrong!</div>}
emptyView={<EmptyState />}
isEmpty={isInvoiceListEmpty}
>
{([response]: [InvoiceListResponse]) => (
<Details
invoice={response.data[0]}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</ResponseHandler>
);
};
This component is responsible for gathering data based on the provided dealId
prop. It calls the Custom Objects API to find the related record of the custom object type invoice
for a given dealId
.
It uses the useClientRequest hook to perform a GET request on the Related Object Records API. As previously mentioned, the one-to-one relationship type is defined as:
{
key: 'deal_invoice',
source: 'zen:deal',
target: 'invoice',
....
}
An example request to fetch a related invoice for the deal would look like this:
https://{your_sell_subdomain}/api/sunshine/objects/records/zen:deal:21730067/related/deal_invoice
where 21730067
is the dealId
of a deal from the current location. It is provided as a prop and deal_invoice
is the relationship type key.
In this scenario, <ResponseHandler/>
also covers asynchronous requests. It also provides an isEmpty
method as a prop to check whether the response is empty or not. If no invoice records are created, an emptyView
prop is rendered which is an <EmptyState/>
component.
When the response is not empty, an invoice record is passed to a Details.js component responsible for rendering its attributes.
Creating a custom object and relationship
This section describes a situation when there is no invoice record and you would like to add a new record.
The EmptyState.tsx component responsible for handling this scenario displays a button to add a new Invoice
and navigates to NewView.tsx in the /new
path.
const EmptyState = () => {
return (
...
<Link to="/new">
<Button data-test-id="invoice-new">Add Invoice</Button>
</Link>
...
)
}
The app uses standard Zendesk Garden UI components such as a Button.
As mentioned above, adding a new Invoice
record is handled by NewView.tsx.
const NewView = () => {
useClientHeight(400)
const history = useHistory()
const dealIdResponse = useClientGet('deal.id')
const client = useContext(ZAFClientContext)
const handleSubmittedForm = useCallback(
async (attributes: NewFormAttributes) => {
const invoiceResponse = (await createInvoice(
client,
attributes,
)) as InvoiceResponse
await createRelation(client, attributes.dealId, invoiceResponse.data.id)
history.push('/')
},
[],
)
return (
<ResponseHandler
responses={[dealIdResponse]}
loadingView={<Loader />}
errorView={<div>Something went wrong!</div>}
emptyView={<div>There's nothing to see yet.</div>}
>
{([dealId]: [number]) => (
<NewForm dealId={dealId} onSubmittedForm={handleSubmittedForm} />
)}
</ResponseHandler>
)
}
This component renders <NewForm>
with a dealId
and a onSubmittedForm
prop that is invoked when the form is submitted.
The handleSubmittedForm
function gets invoice attributes passed from the form and performs two actions - createInvoice
and createRelation
implemented in src > providers > SunshineProvider.ts.
createInvoice
export const createInvoice = (
client: Client | undefined,
attributes: NewFormAttributes
) => {
const body = {
data: {
type: OBJECT_TYPE,
attributes: {
invoice_number: attributes.invoiceNumber,
issue_date: attributes.issueDate,
due_date: attributes.dueDate,
due_amount: parseFloat(attributes.dueAmount),
is_paid: attributes.isPaid,
},
},
};
return client?.request({
url: `/api/sunshine/objects/records`,
method: "POST",
contentType: "application/json",
data: JSON.stringify(body),
});
};
A POST request is made to Create Object Record API endpoint and creates a new invoice record. The client performing the request is an instance of the ZAF Client initialized in the <App>
component.
In the response, the id
in the Invoice
record is used to create a relationship between the deal and invoice.
createRelation
export const createRelation = (
client: Client | undefined,
dealId: number,
invoiceId: string
) => {
const data = {
data: {
relationship_type: RELATION_TYPE,
source: `zen:deal:${dealId}`,
target: invoiceId,
},
};
return client?.request({
url: `/api/sunshine/relationships/records`,
method: "POST",
contentType: "application/json",
data: JSON.stringify(data),
});
};
This method runs after the createInvoice
response and as a parameter requires the dealId
and invoiceId
parameters. It then makes a POST request to the Create Relationship Record API endpoint and creates a new record of linking the invoice to a deal. The client performing the request (passed as a parameter) is also an instance of the ZAF Client initialized in an <App>
component.
In the end, you navigate back to EntryView
using history.push('/')
available by using React Router. At this point it loads the newly created invoice described earlier in this section.
Editing an object
In this section, you'll learn how object records are edited using Custom Objects API. It occurs when you navigate to /edit
from <Details>
. This action is handled by EditView
function in the EditView.tsx file.
const EditView = ({ dealId }: { dealId: string }) => {
const history = useHistory();
const client = useContext(ZAFClientContext);
const sunshineResponse = useClientRequest(
`/api/sunshine/objects/records/zen:deal:${dealId}/related/deal_invoice`
);
const handleSubmittedForm = useCallback(
async (invoiceId: string, attributes: EditFormAttributes) => {
await updateInvoice(client, invoiceId, attributes);
history.push("/");
},
[]
);
const isInvoiceListEmpty = (response: { data: InvoiceListResponse }) =>
response.data.data.length === 0;
return (
<ResponseHandler
responses={[sunshineResponse]}
loadingView={<Loader />}
errorView={<div>Something went wrong!</div>}
emptyView={<div>Couldn't find any related invoices</div>}
isEmpty={isInvoiceListEmpty}
>
{([response]: [InvoiceListResponse]) => (
<EditForm
invoice={response.data[0]}
onSubmittedForm={handleSubmittedForm}
/>
)}
</ResponseHandler>
);
};
First, you retrieve an Invoice
record from the List Related Object Records API to edit its current attributes:
const sunshineResponse = useClientRequest(
`/api/sunshine/objects/records/zen:deal:${dealId}/related/deal_invoice`
);
The response is handled by <ResponseHandler>
and passed to <EditForm>
along with the onSubmittedForm
prop.
Similarly, to the Create
function handleSubmittedForm
, invoice attributes are passed from the form and performs an action where the updateInvoice
implemented within the sunshineProvider.ts file.
updateInvoice
export const updateInvoice = (
client: Client | undefined,
invoiceId: string,
attributes: EditFormAttributes
) => {
const body = {
data: {
attributes: {
invoice_number: attributes.invoiceNumber,
issue_date: attributes.issueDate,
due_date: attributes.dueDate,
due_amount: parseFloat(attributes.dueAmount),
is_paid: attributes.isPaid,
},
},
};
return client?.request({
url: `/api/sunshine/objects/records/${invoiceId}`,
method: "PATCH",
contentType: "application/merge-patch+json",
data: JSON.stringify(body),
});
};
Based on invoiceId
provided in the parameters, this method makes a PATCH request to the Update Object Record endpoint. Note, the Content-Type is specified as "application/merge-patch+json".
Deleting an object and relationship
The last action available in the app is detaching the invoice record from a deal. It can be performed from the <Details>
view by clicking the button which navigates to the /delete
path handled by the <DeleteView>
component.
const DeleteView = ({dealId}: {dealId: string}) => {
const dealRelationName = `zen:deal:${dealId}`
const client = useContext(ZAFClientContext)
const history = useHistory()
const sunshineResponse = useClientRequest(
`/api/sunshine/relationships/records?type=${RELATION_TYPE}`,
)
const handleDelete = useCallback(
async (relationId: string, invoiceId: string) => {
await deleteRelation(client, relationId)
await deleteObject(client, invoiceId)
history.push('/')
},
[],
)
const isRelationEmpty = (response: {data: RelationshipListResponse}) =>
response.data.data.filter(
(relation: RelationshipData) => relation.source === dealRelationName,
).length === 0
return (
<ResponseHandler
response={sunshineResponse}
loadingView={<Loader />}
errorView={<div>Something went wrong!</div>}
emptyView={<div>Couldn't find any related invoices</div>}
isEmpty={isRelationEmpty}
>
{([response]: [RelationshipListResponse]) => (
<DeleteSection
relation={
response.data.find(
(relation: RelationshipData) =>
relation.source === dealRelationName,
) as RelationshipData
}
onDelete={handleDelete}
/>
)}
</ResponseHandler>
)
}
It works similar to create
action. Once the handleDelete
method is invoked, two actions, deleteRelation
and deleteInvoice
are implemented in the sunshineProvider.ts file. Requests to the Custom Objects API are made in this order to first detach the relationship, then remove the custom objects record.
deleteRelation and deleteInvoice
export const deleteRelation = (
client: Client | undefined,
relationId: string
) => {
return client?.request({
url: `/api/sunshine/relationships/records/${relationId}`,
method: "DELETE",
});
};
export const deleteObject = (client: Client | undefined, objectId: string) => {
return client?.request({
url: `/api/sunshine/objects/records/${objectId}`,
method: "DELETE",
});
};
DELETE requests are made to the Delete Object Record and Delete Relationship Record endpoints, requiring the id
of a given object.
The client performing the request is an instance of the ZAF Client initialized in the <App>
component and passed as an argument.
Developing the app
Go ahead and experiment with changes in the app. You can test the app locally and use Zendesk CLI to validate and package the app before uploading it to Sell.
To further develop the app, it is recommended to use nodeJS v14.15.3 and npm v6.14.9.
Testing the app locally
The Zendesk CLI (ZCLI) includes a local web server so you can run and test your apps locally as you're developing it. Run it often to test your latest changes.
Note: It is recommended to use private browsing or the Incognito mode in your browser when testing and developing apps. Your browser may cache certain files used by the app. If a change is not working in your app, the browser might be using an older cached version of the file. With private browsing, files aren't cached.
Test your app
-
In your command-line interface, navigate to the sell-custom-objects-app-tutorial folder.
-
Install dependencies:
$ npm install
-
Start your app:
$ npm start
-
Open a new window in your command line tool and start the server:
$ npm run server
-
Go to the Deals page and select a deal from the list to open a deal card. The URL should look something like this:
https://app.futuresimple.com/sales/deals/123
-
Append
?zcli_apps=true
to the Deal card URL and press Enter. Example:https://app.futuresimple.com/sales/deals/123?zcli_apps=true
-
If you're using the Google Chrome browser, the content of your app may be blocked. Click the lock icon on the left side of the address bar and select Site settings. On the Settings page, scroll to the Insecure Content section, and select Allow.
Note: Firefox doesn't block app content but Safari does and has no option to disable blocking.
Packaging and uploading the app to Sell
To validate the app and package it in a zip file, in your command line tool run:
$ npm run build
The output confirms a new zip file has been generated. The file can be found in the dist/tmp/ folder. See Installing the app to upload the zip file to Sell.