Building a UI form
This page is a tutorial on how you can code a generic frontend form which works with all Tray connectors and operations.
It will give you a head start in building a UI form that you will ultimately present for your End Users.
The code used for this tutorial is what drives our Connector Tester app
You can clone the code from the Connector Tester GitHub repo.
It is highly recommended that you install and run the demo app in order to play with the services you plan to use in your application.
This will give you a good understanding of what is involved in terms of the options presented
Tools used
JSON Schema
We use JSON Schema to decribe the data formats for inputs and outputs of our connectors.
In a nutshell, JSON Schema is a declarative language to annotate and validate a JSON document with standard definitions.
If you are interested in reading more about JSON Schema, you can check out their step-by-step guide here.
React + MUI
To build the UI of the app.
Express.js
To build a proxy server that powers UI.e
High level diagram
Step by step guide:
1. Prepare the Input schema for form
As mentioned, every operation on a Tray connector has a predefined input format declared using JSON schema. You can use this input schema directly or modify it to only include the fields you would want to render.
To render the schema, the app uses a popular JSON Schema renderer library called RJSF
info
JSON schema is an industry standard and hence there are several other open source libraries worth exploring, you could also code your own JSON Schema renderer component.
You can also merge schemas of two operations if you are building integrations between two services.
Click here to see the input schema for an integration that pushes contacts
from a Google Spreadsheet to Mailchimp:
2. Render the Input schema
Here's a code sample for creating a schemaRenderer
component using RJSF's MUI theme. RJSF provides support for multiple CSS libraries including MUI, Bootstrap, Chakra and many more so you can choose the one that fits into your tech stack.
import { useState } from "react";
import validator from "@rjsf/validator-ajv8";
import Form from "@rjsf/mui";
export const schemaRenderer = ({ inputSchema }) => {
const [inputPayload, setInputPayload] = useState({});
return (
<Form
schema={inputSchema} //schema can be passed as a prop from App.js
validator={validator}
formData={inputPayload}
onChange={async (e) => {
//logic for Updating the schema
}}
onSubmit={(e) => {
//logic for Calling the connector
}}
/>
);
};
info
Apart from RJSF, there are several other open source JSON Schema renderer libraries that might be useful for your tech stack:
- jsonforms.io - Supports React, Angular and Vue
- jsonform - Plain HTML5
- angular-schema-form - Angular only
- vue-json-schema-form - Vue only
3. Take user auth
For calling a Tray connector on behalf of your user, you would need the user's authId
for that service.
Pre-existing Auths
You may have pre-existing auths for that connector owned by the end user. To obtain the user auths, you can use GET user authentications (user-token).
async function getAuthentications(bearerToken) {
const config = {
headers: {
Authorization: `Bearer ${bearerToken}`,
},
};
const response = await axios.get(`${API_URL}/authentications`, config);
return response?.data?.data?.viewer?.authentications?.edges;
}
Note that, This API call will give you all auths owned by the end user. You will have to filter the auths using service name and service version for the connector that you are using.
Here's how you can do it:
async function getConnectorAuthentications(service, version, authentications) {
const filteredList = authentications
.filter(
(auth) =>
auth.node.service.name === service &&
auth.node.service.version == version
)
.map((connectorAuth) => {
return {
id: connectorAuth.node.id,
name: connectorAuth.node.name,
};
});
return filteredList;
}
Service name and version can be obtained from GET connectors
Once, you have the exiting auths, You can present a dropdown list to your end user where they can select the auth.
New Auths
If the end user doesn't own an existing auth for the given service, you can present them with a button that will open the auth-only dialog upon click.
For this, you will have to assemble the auth-dialog URL, Refer this page for more details.
The auth only dialog can be whitelabelled with your branding to include your URL.
The auth dialog opens as a popup and sends events to the window from where we opened it. These events are helpful to perform error handling (user closes the auth dialog without completing it etc.) on a failure event and to capture the authId from a sucecss event. You can find the list of events here.
Here's a code sample on how this can be implemented:
export const openAuthWindow = (url) => {
// Must open window from user interaction code otherwise it is likely
// to be blocked by a popup blocker:
const authWindow = window.open(
undefined,
"_blank",
"width=500,height=500,scrollbars=no"
);
const onmessage = (e) => {
console.log("message", e.data.type, e.data);
if (e.data.type === "tray.authPopup.error") {
// Handle popup error message
alert(`Error: ${e.data.error}`);
authWindow.close();
}
if (
e.data.type === "tray.authpopup.close" ||
e.data.type === "tray.authpopup.finish"
) {
authWindow.close();
}
};
window.addEventListener("message", onmessage);
// Check if popup window has been closed
const CHECK_TIMEOUT = 1000;
const checkClosedWindow = () => {
if (authWindow.closed) {
window.removeEventListener("message", onmessage);
} else {
setTimeout(checkClosedWindow, CHECK_TIMEOUT);
}
};
checkClosedWindow();
authWindow.location = url;
};
const authDialogURL = `https://${AUTH_DIALOG_URL}/external/auth/create/${PARTNER_NAME}?code=${json.data?.generateAuthorizationCode?.authorizationCode}&serviceId=${serviceId.current}&serviceEnvironmentId=${selectedServiceEnvironment.id}&scopes[]=${scopes}`;
openAuthWindow(authDialogURL);
4. Updating input schema with independent DDLs
DDLs are dynamic dropdown lists which means they are essentially dropdown lists (<select>
tag in HTML) bu they are dynamic as the options in the list would change if using a different auth or unser input from a different field.
Every DDL operation contains a lookup
key with the operation name that's powering it and the inputs required for that operation.
Independent DDLs (Dynamic dropdown lists) in Tray refer to the DDL operations that are dependent only on the user auth but don't change on user inputs.
For example, channel
field in Slack is a dropdwon list. The lookup opeation - list_users_and_conversations_ddl
does not require any user inputs. This operation requires user auth to list the slack channels from their workspace.
An independent DDL's input
field will be an empty object as shown below:
...
...
"channel": {
"type": "string",
"description": "The user or channel the message is being sent to.",
"lookup": {
"operation": "list_users_and_conversations_ddl",
"input": {} //blank input object
},
"title": "Channel"
}
...
...
As soon as the user selects an auth on your form, you should populate all independent DDLs with their enum list values.
Here's how you may do it in your codebase:
async function populateDDLSchema(inputSchema) {
for (let key in inputSchema.properties) {
// JSONata expression to check for deeply nested key
const expression = jsonata("**.lookup");
// check if key contains a `lookup` key within it
const result = await expression.evaluate(inputSchema.properties[key]);
// check if the lookup is an independent DDL (ie. input is an empty object)
if (typeof result === "object" && JSON.stringify(result.input) === "{}") {
const body = {
operation: result.operation,
authId: selectedAuthentication.id, // selectedAuthentication is a state value of the app
input: {},
returnOutputSchema: false,
};
// call the proxy server. selectedConnectorName and selectedConnectorVersion are state values of the app
const response = await axios.post(
`${API_URL}/connectors/${selectedConnectorName}/versions/${selectedConnectorVersion}/call`,
body,
{
headers: {
Authorization: `Bearer ${token}`, // token is a state value of the app
"Content-Type": "application/json",
},
}
);
/*
response of the lookup operation is an array of objects in the following format:
[
{
value: "",
text: ""
},
{
value: "",
text: ""
}
...
]
*/
// prepare an array of option values from the result of lookup.
const enums = await jsonata(`[output.result.value]`).evaluate(
response?.data
);
// prepare an array of option labels from the result of lookup.
const enumNames = await jsonata(`[output.result.text]`).evaluate(
response?.data
);
if (enums.length > 0) {
/* The JSONata expression below transforms the inputSchema
object by adding the enum values and labels at the key level */
const newInputSchema = await jsonata(`$~>|**|{
"enum":lookup.operation=$operation?$enumValues,
"enumNames":lookup.operation=$operation?$enumLabels
}|`).evaluate(JSON.parse(JSON.stringify(inputSchema)), {
operation: result.operation,
enumValues: enums,
enumLabels: enumNames,
});
setInputSchema(() => newInputSchema);
}
}
}
}
5. Updating input schema with dependedent DDLs
A dependent DDL operation is a field whose lookup
key has a non-empty input
field.
For example, list_worksheets_ddl_return_name
is a dependent DDL in the Sheets connector as it's dependent on spreadsheet_id
.
...
...
"worksheet_name": {
"type": "string",
"description": "The name of the sheet inside the spreadsheet.",
"lookup": {
"operation": "list_worksheets_ddl_return_name",
"input": {
"spreadsheet_id": "{{spreadsheet_id}}" //input object is non-empty
}
},
"title": "Worksheet name"
}
...
...
Once the user starts filling the form, you will have to write a function to update schema whenever you have all the inputs required for a dependent DDL.
Here's how you may do it in your codebase:
// JSONata expression to look for dependent DDL operations. It's looking for all fields which contain a `lookup` and the input key within the lookup is a non-empty object.
const depedentDDLs = await jsonata(`[**.lookup[input!={}]]`).evaluate(
inputSchema
);
// dependentDDLOperations is a ref value created using useRef hook
dependentDDLOperations.current = depedentDDLs;
// this function is called onChange of the <Form> component (Schema Renderer)
async function checkAndPopulateDependentDDL(formData) {
for (let i = 0; i < dependentDDLOperations.current?.length; i++) {
const ddlOperation = dependentDDLOperations.current[i];
const inputs = Object.keys(ddlOperation.input);
/* check if all fields required in the input object
of the DDL operation are present in the formData */
if (
inputs.every(
(key) =>
formData[
ddlOperation.input[key].slice(
ddlOperation.input[key].lastIndexOf("{") + 1,
ddlOperation.input[key].indexOf("}")
)
]
)
) {
// prepare the input object to be sent as the body of the DDL operation API call
const bodyInputObject = {};
inputs.forEach(
(input) =>
(bodyInputObject[input] =
formData[
ddlOperation.input[input].slice(
ddlOperation.input[input].lastIndexOf("{") + 1,
ddlOperation.input[input].indexOf("}")
)
])
);
const body = {
operation: ddlOperation.operation,
authId: selectedAuthentication.id, // selectedAuthentication is a state value of the app
input: bodyInputObject,
returnOutputSchema: false,
};
// call the proxy server. selectedConnectorName and selectedConnectorVersion are state values of the app
const response = await axios.post(
`${API_URL}/connectors/${selectedConnectorName}/versions/${selectedConnectorVersion}/call`,
body,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
// prepare an array of option values from the result of lookup.
const enums = await jsonata(`[output.result.value]`).evaluate(
response?.data
);
// prepare an array of option labels from the result of lookup.
const enumNames = await jsonata(`[output.result.text]`).evaluate(
response?.data
);
if (enums.length > 0) {
/* The JSONata expression below transforms the inputSchema
object by adding the enum values and labels at the key level */
const newInputSchema = await jsonata(`$~>|**|{
"enum":lookup.operation=$operation?$enumValues,
"enumNames":lookup.operation=$operation?$enumLabels
}|`).evaluate(inputSchemaRef.current, {
operation: ddlOperation.operation,
enumValues: enums,
enumLabels: enumNames,
});
inputSchemaRef.current = newInputSchema;
dependentDDLOperations.current.splice(i, 1);
}
}
}
}
6. Handling form submit
You can code the onSubmit event on the form to perform the Call connector operation using the form data.
With RJSF, the formData can be easily accessed from the event object as it's present as a separate key.
onSubmit={(e) => {
callConnector(e.formData);
}}
Your business logic would also go in here if you have to call multiple operations and transform the data in between steps.
Here's an example for the onSubmit event of an integration to migrate contacts from Google Sheets
to Mailchimp
.
onSubmit={async (e) => {
//destructuting form data
const {
spreadsheet_id,
worksheet_name,
list_id,
mapping,
mailchimp_subscription_status,
} = e.formData;
//preparing the mapping object
const mappingObject = await jsonata(`
$merge($map($, function($v, $i){
{
$v.sheet_fields : $v.mailchimp_fields
}
}))
`).evaluate(mapping);
// preparing payload for `get_total_active_rows` operation on Sheets connector
const getActiveRowsPayload = {
operation: "get_total_active_rows",
authId: selectedSheetsAuthentication, //selectedSheetsAuthentication is a state value
input: {
spreadsheet_id: spreadsheet_id,
worksheet_name: worksheet_name,
},
returnOutputSchema: false,
};
// calling the `get_total_active_rows` operation on Sheets connector
const activeRowsResponse = await callConnector(
'sheets',
'8.1',
getActiveRowsPayload
);
const numOfRows = activeRowsResponse.output.rows;
// preparing payload for `get_rows` operation in Sheets
const getRowsPayload = {
operation: "get_rows",
authId: selectedSheetsAuthentication, //selectedSheetsAuthentication is a state value
input: {
spreadsheet_id: spreadsheet_id,
worksheet_name: worksheet_name,
number_of_rows: numOfRows,
format_response: true,
},
returnOutputSchema: false,
};
// calling the `get_rows` operation on Sheets connector
const sheetRows = await callConnector(
'sheets',
'8.1',
getRowsPayload
);
const transformedRowsResponse = await jsonata("$.result").evaluate(
sheetRows.output.results
);
const sheetsFields = Object.keys(transformedRowsResponse[0]);
// the function transforms the data as per the acceptable input schema of Mailchimp's batch subscribe operation
const membersArray = preparePayloadForMailchimpBatchSubscribe(
transformedRowsResponse,
mappingObject,
sheetsFields,
mailchimp_subscription_status
);
const mailchimpBatchSubcribePayload = {
operation: "raw_http_request",
authId: selectedMailchimpAuthentication,
input: {
method: "POST",
include_raw_body: false,
parse_response: "true",
url: {
endpoint: `/lists/${list_id}`,
},
query_parameters: [
{
key: "skip_duplicate_check",
value: "true",
},
],
body: {
raw: {
members: membersArray,
},
},
},
returnOutputSchema: false,
};
const resultOfSubmit = await callConnector(
mailchimp.connectorName,
mailchimp.connectorVersion,
mailchimpBatchSubcribePayload
);
}}