Building Freshworks apps that will run on two different Freshworks host products. Like, When you have published an app to Freshsales and want to publish the same to Freshworks CRM. We will step by step acknowledge that with the same codebase, we will serve the users running the app in different runtime environments of the host Freshworks product.
Or you want to build a single app and publish to both the products since the functionality of these products are similar.
Freshsales and Freshworks CRM are two different products. So we build our app assuming you want your app to have one build that can run with both segments, which use either Freshsales or Freshworks CRM.
A sample app and compare its every aspect to standalone Freshworks App.
iparams.json
iparams.html
Get the starter code ready
git clone https://github.com/freshworks-developers/omnify-freshworks-apps.git
Alternatively,
fdk run
The App that we are going to build is a sample app that demonstrates each of the Omni capabilities of the platform.
Let's create a config/
directory. Then, in the same directory, create the iparams.json
file.
In the iparams.json
file, write the configuration as follows,
{
"api_key": {
"display_name": "ApiKey",
"description": "Please enter your API Key",
"type": "api_key",
"required": true,
"type_attributes": {
"product": "current"
},
"secure": true
},
"freshworks_crm_field": {
"display_name": "A field on Freshworks CRM",
"description": "An example description of field in Freshworks CRM",
"type": "text",
"required": true
},
"freshsales_field": {
"display_name": "A field on Freshsales",
"description": "An example description of field in Freshsales",
"type": "text",
"required": true
}
}
If you choose to simulate either of the products, you will see the same configuration page rendered as follows.
We will have to tell the platform to render the right page based on the host Freshworks product. To do it, lets go ahead and create another directory within config/
folder and name it assets/
Create iparams.js
file within assets/
directory
Start writing the following Javascript code,
async function onFormLoad() {
window.client = await app.initialized();
let { name: currentProduct } = client.context.productContext;
renderConfigPage(currentProduct);
}
function renderConfigPage(currentProduct) {
let product = String(currentProduct);
switch (product) {
case 'freshsales':
utils.set('api_key', personalizeAPIField('Freshsales'));
utils.set('freshworks_crm_field', { visible: false });
break;
case 'freshworks_crm':
utils.set('api_key', personalizeAPIField('Freshworks CRM'));
utils.set('freshsales_field', { visible: false });
break;
}
function personalizeAPIField(name) {
return {
hint: `Please enter API key of ${name}`,
label: `${name} API key`
};
}
}
async function establishAPIConnection(api_key) {
try {
let { name: productName, url: host_url } = client.context.productContext;
let options = {
headers: {
Authorization: `Token token=${api_key}`,
'Content-Type': 'application/json'
}
};
let URL = genHostEndpoint(productName, host_url);
let res = await client.request.get(URL, options);
if (res.status == '200') return '';
} catch (error) {
console.error(error);
return `Invalid API Key`;
}
function genHostEndpoint(productName, host_url) {
switch (productName) {
case 'freshsales':
return `${host_url}/api/settings/deals/fields`;
case 'freshworks_crm':
return `${host_url}/api/sales_activites`;
}
}
}
function onFormUnload() {
console.log('This statement executes when user leaves the configuration page');
}
iparams.json
and add one more property to the api_key
installation parameter. That is "events": [{ "change": "establishAPIConnection" }]
"events": [{ "change": "establishAPIConnection" }]
property observes if the API key is entered by the user. When the user enters the API key, we will validate with relevant host product Freshworks product APIs if the API key is valid.The onFormLoad()
and onFormUnload()
are two functions that the platform invokes when the app loads or unloads the configuration page
client.context.productContext
is a property that will return an object with two properties - url
and name
For Freshsales
{
"url": "https://subdomain.freshales.io",
"name": "freshsales"
}
For Freshworks CRM
{
"url": "https://sample.myfreshworks.com/crm",
"name": "freshworks_crm"
}
This is how depending on the product name, utils.set(..,{visible:false})
will hide a non-relevant field. For example, if the configuration page renders on Freshsales, Platform will hide the field for Freshworks CRM and vice versa.
Note:
Now that we understand how the app renders configuration pages based on the host Freshworks product, we will explore how to let the app's front-end know the host Freshworks product.
We will focus on the app/ directory to render client.data.get('domainName')
return value to the app's UI in every placeholder.
The UI that renders in the specified placeholders of the Omni uses the HTML, CSS, and JS files from the app/
directory. So let us write the code to make changes as needed,
Index.html
Open the index.html
file and remove all the existing code which comes with a template using fdk create and write the following code
<html>
<head>
<script src="https://static.freshdev.io/fdk/2.0/assets/fresh_client.js"></script>
<script type="module" src="https://unpkg.com/@freshworks/crayons/dist/crayons/crayons.esm.js"></script>
<script nomodule src="https://unpkg.com/@freshworks/crayons/dist/crayons/crayons.js"></script>
<link rel="stylesheet" href="styles/styles.css" />
</head>
<body>
<article>
<section>
<h1>Data Method Demonstration</h1>
<p>Here is the what Data Method of Domain responded with</p>
<code>
<p id="result"></p>
</code>
</section>
<section></section>
</article>
</body>
<script defer src="scripts/app.js"></script>
</html>
scripts/app.js
element with id attribute values result. We will render the value that Data Method responds with at the part.html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
article {
display: flex;
flex-flow: row wrap;
justify-content: space-around;
align-items: stretch;
}
section {
width: 45%;
height: 35%;
}
h1 {
font-size: 20px;
}
Nothing special. We simply use flexbox to position the elements in a user-friendly manner. You can choose to remove the views/modal.html that come with template code at this point.
app/scripts/app.js
Let's have app.js to let the app know the context of its host Freshworks product.
Remove any existing code and write the following,
(async function init() {
let client = await app.initialized();
client.events.on('app.activated', async function getData() {
let data = await client.data.get('domainName');
writeToDOM(data);
});
})();
function writeToDOM(data) {
const renderUIwith = document.querySelector('#result');
const domainNameElement = `
<p>
${JSON.stringify(data, null, 4)}
</p>
`;
console.table(data);
renderUIwith.insertAdjacentHTML('afterbegin', domainNameElement);
}
The client.data.get('domainName')
resolves with following JSON data,
For Freshsales,
{
"domainName": "subdomain.freshsales.io",
"productContext": {
"url": "https://subdomain.freshsales.io",
"name": "freshsales"
}
}
In the UI
For Freshworks CRM
{
"domainName": "subdomain.freshworks.com",
"productContext": {
"url": "https://subdomain.freshworks.com/crm",
"name": "freshworks_crm"
}
}
In the UI,
Freshworks platform gives the developers capabilities to help the app observe and act when certain events occur in the host Freshworks product.
Usually, you will be required to declare it in the server.js
as follows:
exports = {
events: [{ event: 'eventName', callback: 'eventCallbackMethod' }],
eventCallbackMethod: function (payload) {
//Multiple events can access the same callback
console.log('Logging arguments from the event: ' + JSON.stringify(payload));
}
};
With platform version 2.1, you can declare all the events in the manifest file. In this section, we will explore how to do it.
manifest.json
Go back to the manifest.json
and rewrite it as follows:
{
"platform-version": "2.1",
"product": {
"freshworks_crm": {
"location": {
"deal_entity_menu": {
"url": "index.html",
"icon": "styles/images/icon.svg"
},
"sales_account_entity_menu": {
"url": "index.html",
"icon": "styles/images/icon.svg"
},
"contact_entity_menu": {
"url": "index.html",
"icon": "styles/images/icon.svg"
}
},
"events": {
"onContactCreate": {
"handler": "onContactCreateHandler"
},
"onDealCreate": {
"handler": "onDealCreateHandler"
},
"onExternalEvent": {
"handler": "handleExtEventForCRM"
}
}
},
"freshsales": {
"location": {
"left_nav_chat": {
"url": "index.html",
"icon": "styles/images/icon.svg"
},
"left_nav_cti": {
"url": "index.html",
"icon": "styles/images/icon.svg"
},
"deal_entity_menu": {
"url": "index.html",
"icon": "styles/images/icon.svg"
},
"sales_account_entity_menu": {
"url": "index.html",
"icon": "styles/images/icon.svg"
}
},
"events": {
"onLeadCreate": {
"handler": "onLeadCreateHandler"
},
"onDealCreate": {
"handler": "onDealCreateHandler"
},
"onExternalEvent": {
"handler": "handleExtEventForSales"
}
}
}
},
"whitelisted-domains": ["https://*.freshsales.io", "https://*.freshworks.com", "https://*.myfreshworks.com"]
}
onContactCreate
is a key.whitelisted-domains
property on the manifest.json
. Freshworks CRM requires adding both https://subdomain.freshworks.com
and https://subdomain.myfreshworks.com
to the allow list. Two domains because users who will use the app might have either of the domains.server.js
Till now, we haven't created any server files. So let's go ahead and create one.
Create server/
dir with server.js
in it.
Write the following code in server.js
function logMsg(msg, payload) {
let {
productContext: { name: productName }
} = payload;
let log = console.info;
log(`
\nProduct: ${productName}
\nWhen: ${msg}
\nPayload: ${JSON.stringify(payload, ['domain', 'event'], 2)}
`);
}
exports = {
onLeadCreateHandler: function freshsales(payload) {
logMsg('A lead is created in Freshsales', payload);
},
onContactCreateHandler: function freshworks_crm(payload) {
logMsg('A contact is created in Freshworks CRM', payload);
},
onDealCreateHandler: function common(payload) {
logMsg('A Deal is created either in Freshsales or in Freshworks CRM', payload);
},
handleExtEventForSales: function (payload) {
logMsg('A desired external event occurs for Freshsales', payload);
},
handleExtEventForCRM: function (payload) {
logMsg('A desired external event occurs for Freshworks CRM', payload);
}
};
onLeadCreateHandler
and onContactCreateHandler
. Both of them invoke the methods when respective events occur in Freshsales and Freshworks CRM, respectively.onDealCreate
serverless events. Therefore, you can define the same method in such cases, although either host Freshworks product triggers the event.handleExtEventForSales
and handleExtEventForCRM
have two different methods.Properties within exports
object simply log messages to the console.
The logMsg(..)
function simply logs the data to the console. Notably, the payload that passed will have the same productContext
property(same as in Data Method) that is helpful for the app to understand if the payload is coming from Freshsales or Freshworks CRM.
Let's simulate one of the events.
fdk run
You will find the following simulation page running on the localhost
To simulate product, app setup, and external events, visit - `http://localhost:10001/web/test`
To test the installation page, visit - `http://localhost:10001/custom_configs`
We want to simulate product, app setup, and external events,
As soon as you open http://localhost:10001/web/test
, you will get to see a product switcher
This product switcher will help you simulate the event in whichever product you want to simulate the events locally. For example, if I choose Freshworks CRM,
You get a drop-down of events you want to simulate along with the sample payload generated to test your application.
Above is a screenshot of the log to the console when simulated onDealCreate
serverless event.
We've built the complete app we intended to create.
Freshworks lets developers build the configuration pages for their app using iparams.json if their use cases require more levels of customization.
In those cases how do we handle this omnification?
You can render the configuration page as soon as HTML loads the assets/iparams.js included.
Following is a sample iparams.js
code
(async function () {
const client = await app.initialized();
let { name: productName } = client.context.productContext;
renderConfigPage(productName);
})();
function renderConfigPage(productName) {
const titleHTML = document.querySelector('header.product-specific.title');
const subTitleHTML = document.querySelector('legend.product-specific.subtitle');
titleHTML.innerHTML = `Omni - Custom configuration page <span> ${productName}</span> 🌍`;
subTitleHTML.innerHTML = `${productName} Specific Elements Implementation`;
}
productContext
object.We have looked at all the distinct parts of what platform version 2.1 brings to help you omnify your Freshworks apps.
Note: We approached this tutorial from the lens of Freshsales and Freshworks CRM to be two separate products. Freshsales has halted new registrations. All new users will automatically register with Freshworks CRM. Existing users of Freshsales will continue to use Freshsales.
At the current point, omnifying Freshworks apps are only available for Freshworks CRM and Freshsales. Please feel free to raise a feedback topic on the forum.
All the concepts learned,
iparams.json
and iparams.html
productContext
manifest.json
It will be relevant for any host Freshworks product in the future.
See the final source code of the app.
Kudos for completing the tutorial! Until next time!