Development
Development
Create General Plugin
With erxes, you can create your own plugins or extend the existing ones, which would help you to enhance your experience...
Create Integration plugin
Integration is the extent of the Inbox plugin, which allows third party softwares to be integrated to your shared Inbox.
Create General Plugin
With erxes, you can create your own plugins or extend the existing ones, which would help you to enhance your experience and increase your revenue by adding the value on your products/services or selling it on our our marketplace. This guideline will help you to develop your own plugins.
✋ Caution
- Before you start developing your own plugins, ensure there is no plugins with the same name or similar name in our marketplace that would bring any confusion as the name would be used many places starting from your
API
,GraphQL
,query
,mutation
, etc. - Name must be in small letters with no symbols and space in between.
- Name of All your
GraphQL
type,query
,mutation
must start with your plugin name. - Names of your database collection also must start with your plugin name.
- Name of your UIroutes or
url
-s also must be start with you pluging name.
Installing erxes
Please go to the installation guideline to install erxes XOS, but no need to run the erxes with the same direction.
❌ Danger
We assume you've already installed erxes XOS on your device. Otherwise the guideline below would not work out properly. Please make sure you should be back after you install erxes XOS using the installation guideline.
Plugin API
Plugin development in API part requires the following software prerequisites to be already installed on your computer.
Typescript
TypeScript is a popular choice for programmers accustomed to other languages with static typing, such as C# and Java.
Graphql
GraphQL is a query language for your API, and a server-side runtime for executing queries using a type system you define for your data.
Express.js
Express is a minimal and flexible Node.js web application framework that provides a robust set of features for web and mobile applications.
Mongodb
MongoDB empowers innovators to create, transform, and disrupt industries by unleashing the power of software and data.
Redis
Redis is an open source (BSD licensed), in-memory data structure store used as a database, cache, message broker, and streaming engine.
RabbitMQ
RabbitMQ is a message-queueing software also known as a message broker or queue manager.
Plugin UI
Plugin development in UI part requires the following software prerequisites to be already installed on your computer.
Typescript
TypeScript is a popular choice for programmers accustomed to other languages with static typing, such as C# and Java.
Webpack
Webpack is a module bundler. Webpack can take care of bundling alongside a separate task runner.
Creating New Plugin
Each plugin is composed of two parts, API
and UI
- Create new folders for both using the following command.
cd erxes
yarn create-plugin
The command above starts CLI, prompting for few questions to create a new plugin as shown below. In this example we create plugin named document.
The example below is a new plugin, created from an example template, placed at the main navigation.
Creating from an empty template will result in as shown below, as we give you the freedom and space to develop your own plugin on erxes.
API file structure
After creating a plugin, the following files are generated automatically in your new plugin API.
📦plugin-document-api
┣ 📂src
┃ ┣ 📂graphql
┃ ┃ ┣ 📂resolvers
┃ ┃ ┃ ┣ index.ts
┃ ┃ ┃ ┣ mutations.ts
┃ ┃ ┃ ┗ queries.ts
┃ ┃ ┣ index.ts
┃ ┃ ┗ typeDefs.ts
┃ ┣ configs.ts
┃ ┣ messageBroker.ts
┃ ┗ models.ts
┣ .env.sample
┣ package.json
┗ tsconfig.json
Main files
Following files are generated automatically in plugin-[pluginName]-api/src.
configs.ts
This file contains main configuration of a plugin.
configs.ts file:
// path: ./packages/plugin-[pluginName]-api/src/configs.ts
import typeDefs from './graphql/typeDefs';
import resolvers from './graphql/resolvers';
import { initBroker } from './messageBroker';
export let mainDb;
export let debug;
export let graphqlPubsub;
export let serviceDiscovery;
export default {
name: '[pluginName]',
graphql: async sd => {
serviceDiscovery = sd;
return {
typeDefs: await typeDefs(sd),
resolvers: await resolvers(sd)
};
},
apolloServerContext: async (context) => {
return context;
},
onServerInit: async options => {
mainDb = options.db;
initBroker(options.messageBrokerClient);
graphqlPubsub = options.pubsubClient;
debug = options.debug;
}
}
messageBroker.ts
This file uses for connect with other plugins. You can see message broker functions from Common functions.
messageBroker.ts file:
// path: ./packages/plugin-[pluginName]-api/src/messageBroker.ts
import { ISendMessageArgs, sendMessage } from "@erxes/api-utils/src/core";
import { serviceDiscovery } from "./configs";
import { Documents } from "./models";
let client;
export const initBroker = async cl => {
client = cl;
const { consumeQueue, consumeRPCQueue } = client;
consumeQueue('document:send', async ({ data }) => {
Documents.send(data);
return {
status: 'success',
};
});
consumeRPCQueue('document:find', async ({ data }) => {
return {
status: 'success',
data: await Documents.find({})
};
});
};
export const sendCommonMessage = async (
args: ISendMessageArgs & { serviceName: string }
) => {
return sendMessage({
serviceDiscovery,
client,
...args
});
};
export default function() {
return client;
}
GraphQL development
Inside packages/plugin-<new_plugin>-api/src
, we have a graphql
folder. The folder contains code related to GraphQL.
📂src
┣ 📂graphql
┃ ┣ 📂resolvers
┃ ┃ ┣ index.ts
┃ ┃ ┣ mutations.ts
┃ ┃ ┗ queries.ts
┃ ┣ index.ts
┃ ┗ typeDefs.ts
GraphQL resolvers
Inside /graphql/resolvers/mutations
GraphQL mutation codes.
mutation examples:
import { Documents, Types } from '../../models';
import { IContext } from "@erxes/api-utils/src/types"
const documentMutations = {
/**
* Creates a new document
*/
async documentsAdd(_root, doc, _context: IContext) {
return Documents.createDocument(doc);
},
/**
* Edits a new document
*/
async documentsEdit(
_root,
{ _id, ...doc },
_context: IContext
) {
return Documents.updateDocument(_id, doc);
},
/**
* Removes a single document
*/
async documentsRemove(_root, { _id }, _context: IContext) {
return Documents.removeDocument(_id);
},
/**
* Creates a new type for document
*/
async documentTypesAdd(_root, doc, _context: IContext) {
return Types.createType(doc);
},
async documentTypesRemove(_root, { _id }, _context: IContext) {
return Types.removeType(_id);
},
async documentTypesEdit(
_root,
{ _id, ...doc },
_context: IContext
) {
return Types.updateType(_id, doc);
}
};
export default documentMutations;
Inside /graphql/resolvers/queries
folder contains GraphQL query codes.
query examples:
import { Documents, Types } from "../../models";
import { IContext } from "@erxes/api-utils/src/types"
const documentQueries = {
documents(
_root,
{
typeId
},
_context: IContext
) {
const selector: any = {};
if (typeId) {
selector.typeId = typeId;
}
return Documents.find(selector).sort({ order: 1, name: 1 });
},
documentTypes(_root, _args, _context: IContext) {
return Types.find({});
},
documentsTotalCount(_root, _args, _context: IContext) {
return Documents.find({}).countDocuments();
}
};
export default documentQueries;
GraphQL typeDefs
Inside /graphql/typeDefs.ts
file contains GraphQL typeDefs.
typeDefs:
import { gql } from 'apollo-server-express';
const types = `
type Document {
_id: String!
name: String
createdAt:Date
expiryDate:Date
checked:Boolean
typeId: String
currentType: DocumentType
}
type DocumentType {
_id: String!
name: String
}
`;
const queries = `
documents(typeId: String): [Document]
documentTypes: [DocumentType]
documentsTotalCount: Int
`;
const params = `
name: String,
expiryDate: Date,
checked: Boolean,
typeId:String
`;
const mutations = `
documentsAdd(${params}): Document
documentsRemove(_id: String!): JSON
documentsEdit(_id:String!, ${params}): Document
documentTypesAdd(name:String):DocumentType
documentTypesRemove(_id: String!):JSON
documentTypesEdit(_id: String!, name:String): DocumentType
`;
const typeDefs = async _serviceDiscovery => {
return gql`
scalar JSON
scalar Date
${types}
extend type Query {
${queries}
}
extend type Mutation {
${mutations}
}
`;
};
export default typeDefs;
Database development
Inside packages/plugin-<new_plugin>-api/src
, we have a models
file. The file contains code related to MongoDB and mongoose.
📂src
┗ models.ts
Mongoose schema and model
Inside src/models.ts
, file contains Mongoose schema and models.
Mongoose schema and model example:
import * as _ from 'underscore';
import { model } from 'mongoose';
import { Schema } from 'mongoose';
export const typeSchema = new Schema({
name: String
});
export const documentSchema = new Schema({
name: String,
createdAt: Date,
expiryDate: Date,
checked: Boolean,
typeId: String
});
export const loadTypeClass = () => {
class Type {
public static async getType(_id: string) {
const type = await Types.findOne({ _id });
if (!type) {
throw new Error('Type not found');
}
return type;
}
// create type
public static async createType(doc) {
return Types.create({ ...doc });
}
// remove type
public static async removeType(_id: string) {
return Types.deleteOne({ _id });
}
public static async updateType(_id: string, doc) {
return Types.updateOne({ _id }, { $set: { ...doc } });
}
}
typeSchema.loadClass(Type);
return typeSchema;
};
export const loadDocumentClass = () => {
class Document {
public static async getDocument(_id: string) {
const document = await Documents.findOne({ _id });
if (!document) {
throw new Error('Document not found');
}
return document;
}
// create
public static async createDocument(doc) {
return Documents.create({
...doc,
createdAt: new Date()
});
}
// update
public static async updateDocument (_id: string, doc) {
await Documents.updateOne(
{ _id },
{ $set: { ...doc } }
).then(err => console.error(err));
}
// remove
public static async removeDocument(_id: string) {
return Documents.deleteOne({ _id });
}
}
documentSchema.loadClass(Document);
return documentSchema;
};
loadDocumentClass();
loadTypeClass();
// tslint:disable-next-line
export const Types = model<any, any>(
'document_types',
typeSchema
);
// tslint:disable-next-line
export const Documents = model<any, any>('documents', documentSchema);
UI file structure
After creating new plugin using yarn-create-plugin
command, the following files are generated automatically in your new plugin UI.
📦plugin-[pluginName]-ui
┣ 📂src
┃ ┣ 📂components
┃ ┃ ┣ Form.tsx
┃ ┃ ┣ List.tsx
┃ ┃ ┣ Row.tsx
┃ ┃ ┣ SideBar.tsx
┃ ┃ ┗ TypeForm.tsx
┃ ┣ 📂containers
┃ ┃ ┣ List.tsx
┃ ┃ ┗ SideBarList.tsx
┃ ┣ 📂graphql
┃ ┃ ┣ index.ts
┃ ┃ ┣ mutations.ts
┃ ┃ ┗ queries.ts
┃ ┣ App.tsx
┃ ┣ configs.js
┃ ┣ generalRoutes.tsx
┃ ┣ index.js
┃ ┣ routes.tsx
┃ ┗ types.ts
Main files
Following files are generated automatically in plugin-[pluginName]-ui/src.
configs.js
Following file contains the main configs of plugin.
configs.js file:
// path: ./packages/plugin-[pluginName]-ui/src/configs.js
module.exports = {
name: '[pluginName]',
port: 3017,
scope: '[pluginName]',
exposes: {
'./routes': './src/routes.tsx'
},
routes: {
url: 'http://localhost:3017/remoteEntry.js',
scope: '[pluginName]',
module: './routes'
},
menus:[
{
"text":"[pluginName]",
"url":"/[pluginUrl]",
"icon":"icon-star",
"location":"[mainNavigation or settings]"
}
]
};
routes.tsx
Following file contains routes of plugin UI.
routes.tsx file:
// path: ./packages/plugin-[pluginName]-ui/src/routes.tsx
import asyncComponent from '@erxes/ui/src/components/AsyncComponent';
import queryString from 'query-string';
import React from 'react';
import { Route } from 'react-router-dom';
const List = asyncComponent(() =>
import(/* webpackChunkName: "List - Documents" */ './containers/List')
);
const documents = ({ location, history }) => {
const queryParams = queryString.parse(location.search);
const { type } = queryParams;
return <List typeId={type} history={history} />;
};
const routes = () => {
return <Route path="/documents/" component={documents} />;
};
App.tsx
This file contains main component of application.
App.tsx file:
// path: ./packages/plugin-[pluginName]-ui/src/App.tsx
import React from 'react';
import GeneralRoutes from './generalRoutes';
import { PluginLayout } from '@erxes/ui/src/styles/main';
const App = () => {
return (
<PluginLayout>
<GeneralRoutes />
</PluginLayout>
);
};
export default App;
UI development
Components
Inside .src
folder, we have a components folder. The folder contains main components of plugin.
📂src
┣ 📂components
┃ ┣ Form.tsx
┃ ┣ List.tsx
┃ ┣ Row.tsx
┃ ┣ SideBar.tsx
┃ ┗ TypeForm.tsx
components example:
// path: ./packages/plugin-[pluginName]-ui/src/components/TypeForm.tsx
import { __ } from '@erxes/ui/src/utils/core';
import React from 'react';
import { IType } from '../types';
import { IButtonMutateProps, IFormProps } from '@erxes/ui/src/types';
import Form from '@erxes/ui/src/components/form/Form';
import {
ControlLabel,
FormControl,
FormGroup
} from '@erxes/ui/src/components/form';
import Button from '@erxes/ui/src/components/Button';
import { ModalFooter } from '@erxes/ui/src/styles/main';
type Props = {
renderButton: (props: IButtonMutateProps) => JSX.Element;
closeModal?: () => void;
afterSave?: () => void;
remove?: (type: IType) => void;
types?: IType[];
type: IType;
};
const TypeForm = (props: Props) => {
const { type, closeModal, renderButton, afterSave } = props;
const generateDoc = (values: {
_id?: string;
name: string;
content: string;
}) => {
const finalValues = values;
const { type } = props;
if (type) {
finalValues._id = type._id;
}
return {
...finalValues
};
};
const renderContent = (formProps: IFormProps) => {
const { values, isSubmitted } = formProps;
const object = type || ({} as any);
return (
<>
<FormGroup>
<ControlLabel required={true}>Todo Type</ControlLabel>
<FormControl
{...formProps}
name='name'
defaultValue={object.name}
type='text'
required={true}
autoFocus={true}
/>
</FormGroup>
<ModalFooter id={'AddTypeButtons'}>
<Button btnStyle='simple' onClick={closeModal} icon='times-circle'>
Cancel
</Button>
{renderButton({
passedName: 'type',
values: generateDoc(values),
isSubmitted,
callback: closeModal || afterSave,
object: type
})}
</ModalFooter>
</>
);
};
return <Form renderContent={renderContent} />;
};
export default TypeForm;
Containers
Inside .src
folder, we have a containers folder. The folder contains a component that contains codes related to API.
📂src
┣ 📂containers
┃ ┣ List.tsx
┃ ┗ SideBarList.tsx
containers example:
// path: ./packages/plugin-[pluginName]-ui/src/containers/SideBarList.tsx
import gql from 'graphql-tag';
import * as compose from 'lodash.flowright';
import { graphql } from 'react-apollo';
import { Alert, confirm, withProps } from '@erxes/ui/src/utils';
import SideBar from '../components/SideBar';
import {
EditTypeMutationResponse,
RemoveTypeMutationResponse,
TypeQueryResponse
} from '../types';
import { mutations, queries } from '../graphql';
import React from 'react';
import { IButtonMutateProps } from '@erxes/ui/src/types';
import ButtonMutate from '@erxes/ui/src/components/ButtonMutate';
import Spinner from '@erxes/ui/src/components/Spinner';
type Props = {
history: any;
currentTypeId?: string;
};
type FinalProps = {
listTemplateTypeQuery: TypeQueryResponse;
} & Props &
RemoveTypeMutationResponse &
EditTypeMutationResponse;
const TypesListContainer = (props: FinalProps) => {
const { listTemplateTypeQuery, typesEdit, typesRemove, history } = props;
if (listTemplateTypeQuery.loading) {
return <Spinner />;
}
// calls gql mutation for edit/add type
const renderButton = ({
passedName,
values,
isSubmitted,
callback,
object
}: IButtonMutateProps) => {
return (
<ButtonMutate
mutation={object ? mutations.editType : mutations.addType}
variables={values}
callback={callback}
isSubmitted={isSubmitted}
type="submit"
successMessage={`You successfully ${
object ? 'updated' : 'added'
} a ${passedName}`}
refetchQueries={['listTemplateTypeQuery']}
/>
);
};
const remove = type => {
confirm('You are about to delete the item. Are you sure? ')
.then(() => {
typesRemove({ variables: { _id: type._id } })
.then(() => {
Alert.success('Successfully deleted an item');
})
.catch(e => Alert.error(e.message));
})
.catch(e => Alert.error(e.message));
};
const updatedProps = {
...props,
types: listTemplateTypeQuery.templateTypes || [],
loading: listTemplateTypeQuery.loading,
remove,
renderButton
};
return <SideBar {...updatedProps} />;
};
export default withProps<Props>(
compose(
graphql(gql(queries.listTemplateTypes), {
name: 'listTemplateTypeQuery',
options: () => ({
fetchPolicy: 'network-only'
})
}),
graphql(gql(mutations.removeType), {
name: 'typesRemove',
options: () => ({
refetchQueries: ['listTemplateTypeQuery']
})
})
)(TypesListContainer)
);
GraphQL
Inside .src
folder, we have a graphql
folder. The folder contains code related to GraphQL.
📂src
┣ 📂graphql
┃ ┣ index.ts
┃ ┣ mutations.ts
┃ ┗ queries.ts
Inside /graphql/mutations.ts
GraphQL mutation codes.
GraphQL mutation examples:
const add = `
mutation documentsAdd($name: String!, $expiryDate: Date, $typeId:String) {
documentsAdd(name:$name, expiryDate: $expiryDate, typeId:$typeId) {
name
_id
expiryDate
typeId
}
}
`;
const remove = `
mutation documentsRemove($_id: String!){
documentsRemove(_id: $_id)
}
`;
const edit = `
mutation documentsEdit($_id: String!, $name:String, $expiryDate:Date, $checked:Boolean, $typeId:String){
documentsEdit(_id: $_id, name: $name, expiryDate:$expiryDate, checked:$checked, typeId:$typeId){
_id
}
}
`;
const addType = `
mutation typesAdd($name: String!){
documentTypesAdd(name:$name){
name
_id
}
}
`;
const removeType = `
mutation typesRemove($_id:String!){
documentTypesRemove(_id:$_id)
}
`;
const editType = `
mutation typesEdit($_id: String!, $name:String){
documentTypesEdit(_id: $_id, name: $name){
_id
}
}
`;
export default {
add,
remove,
edit,
addType,
removeType,
editType
};
Inside /graphql/queries.ts
GraphQL query codes.
GraphQL query examples:
const list = `
query listQuery($typeId: String) {
documents(typeId: $typeId) {
_id
name
expiryDate
createdAt
checked
typeId
currentType{
_id
name
}
}
}
`;
const listDocumentTypes = `
query listDocumentTypeQuery{
documentTypes{
_id
name
}
}
`;
const totalCount = `
query documentsTotalCount{
documentsTotalCount
}
`;
export default {
list,
totalCount,
listDocumentTypes
};
Configuring UI
Running port for plugin
Inside packages/plugin-<new_plugin>-ui/src/configs.js
, running port for plugin UI is set as shown below. Default value is 3017. Please note that each plugin has to have its UI running on an unique port. You may need to change the port manually (inside configs.js
) if developing multiple plugins.
module.exports = {
name: 'new_plugin',
port: 3017,
scope: 'new_plugin',
exposes: {
'./routes': './src/routes.tsx'
},
routes: {
url: 'http://localhost:3017/remoteEntry.js',
scope: 'new_plugin',
module: './routes'
},
menus: []
};
Location for plugin
Inside packages/plugin-<new_plugin>-ui/src/configs.js
, we have a configuration section. The example below places new plugin at the main navigation menu.
menus: [
{
text: 'New plugin',
url: '/new_plugins',
icon: 'icon-star',
location: 'mainNavigation',
}
]
If you want to place it only inside settings, example is illustrated below.
menus: [
{
text: 'New plugin',
to: '/new_plugins',
image: '/images/icons/erxes-18.svg',
location: 'settings',
scope: 'new_plugin'
}
]
Enabling plugins
"plugins" section inside cli/configs.json
contains plugin names that run when erxes starts. Please note to configure this section if you decide to enable other plugins, remove or recreate plugins.
{
"jwt_token_secret": "token",
"dashboard": {},
"client_portal_domains": "",
"elasticsearch": {},
"redis": {
"password": ""
},
"mongo": {
"username": "",
"password": ""
},
"rabbitmq": {
"cookie": "",
"user": "",
"pass": "",
"vhost": ""
},
"plugins": [
{
"name": "logs"
},
{
"name": "new_plugin",
"ui": "local"
}
]
}
Running erxes
Please note that create-plugin
command automatically adds a new line inside cli/configs.json
, as well as installs the dependencies necessary.
{
"jwt_token_secret": "token",
"client_portal_domains": "",
"elasticsearch": {},
"redis": {
"password": "pass"
},
"mongo": {
"username": "",
"password": ""
},
"rabbitmq": {
"cookie": "",
"user": "",
"pass": "",
"vhost": ""
},
"plugins": [
{
"name": "new_plugin",
"ui": "local"
}
]
}
- Run the following command
cd erxes/cli
yarn install
- Then run the following command to start erxes with your newly installed plugin
./bin/erxes.js dev
Create Integration plugin
Integration is the extent of the Inbox plugin, which allows third party softwares to be integrated to your shared Inbox.
✋ Caution
Before you're moving forward, please have a read the guideline how you create your own plugin and check out one of our existing integrations available at the marketplace called IMAP which can be found here as we're going to use IMAP integration as an example.
So let's assume, you've already created your plugin by using the above guideline and the name of your plugin is IMAP.
Add the following Inbox-related plugins to configs.json and start the services.
"plugins": [
{
"name": "forms",
"ui": "remote"
},
{
"name": "contacts",
"ui": "local"
},
{
"name": "inbox",
"ui": "local"
},
{
"name": "imap",
"ui": "local"
}
These are the core concepts of the inbox integration
- Brand - Biggest level of data seperation. Let's assume your company is a group company that consists of 3 child companies. In that case each brand will represent each child companies.
- Channel - Group of integrations and team members, which represents who is responsible for which integrations.
- Integration - In IMAP's case, a set of configs that includes email address, password, smtp host etc.
- Customer - In IMAP's case, the person to sent the email.
- Conversation - In IMAP's case, whole email thread.
- Conversation Messages - In IMAP's case, each email entry in single email thread.
Lifecycle of integration
- Create an integration instance with corresponing configs, which will be store in inbox's database and later you will use these to work with the apis you want to connect.
- Receive data from your desired apis using integration configs in plugin-"integration-name"-api.
- Store the data as conversations, conversation messages, and customers. You have to store conversations in inbox's and customers in contacts's database and you have to store conversation messages in your plugin's database.
- Once you stored the conversations and customers. It will show up in inbox's sidebar. But you will be responsive for the conversation detail in inbox's UI.
- Since you can show anything in conversation detail will also be responsible for further actions like sending response to customer.
Let's demonstrate above steps using IMAP as an example
Create an integration
Let's look at configs.js in plugin-imap-ui
inboxIntegration: {
name: 'IMAP',
description:
'Connect a company email address such as [email protected] or [email protected]',
isAvailable: true,
kind: 'imap',
logo: '/images/integrations/email.png',
createModal: 'imap',
createUrl: '/settings/integrations/imap',
category:
'All integrations, For support teams, Marketing automation, Email marketing'
}
It will create following in block in /settings/integrations location
"./inboxIntegrationForm": "./src/components/IntegrationForm.tsx",
and
inboxIntegrationForm: './inboxIntegrationForm',
these lines will show ./src/components/IntegrationForm.tsx
component when you click on the add link in the above picture
When you click on the "Save" button, it will send the message to plugin-imap-api.
So you have to write a consumer like the following
consumeRPCQueue(
'imap:createIntegration',
async ({ subdomain, data: { doc, integrationId } }) => {
const models = await generateModels(subdomain);
const integration = await models.Integrations.create({
inboxId: integrationId,
...doc
});
await listenIntegration(subdomain, integration);
await models.Logs.createLog({
type: 'info',
message: `Started syncing ${integration.user}`
});
return {
status: 'success'
};
}
);
Receive data from your desired APIs
Store the data
const apiCustomerResponse = await sendContactsMessage({
subdomain,
action: 'customers.createCustomer',
data: {
integrationId: integration.inboxId,
primaryEmail: from
},
isRPC: true
});
it will send a createCustomer message to contacts plugin and contact plugin will store it in it's database.
const { _id } = await sendInboxMessage({
subdomain,
action: 'integrations.receive',
data: {
action: 'create-or-update-conversation',
payload: JSON.stringify({
integrationId: integration.inboxId,
customerId,
createdAt: msg.date,
content: msg.subject
})
},
isRPC: true
});
it will send a create or update conversation message to inbox plugin and inbox plugin will store it in it's database.
Conversation detail
in configs.js of plugin-imap-ui
"./inboxConversationDetail": "./src/components/ConversationDetail.tsx",
and
inboxConversationDetail: './inboxConversationDetail',
will render ./src/components/ConversationDetail.tsx
component in conversation detail section of inbox ui
Creating new integration plugin
Each plugin is composed of two parts, API
and UI
- Create new folders for both using the following command.
cd erxes
yarn create-plugin
The command above starts CLI, prompting for few questions to create a new integration plugin as shown below. In this example we create twitter integration.
The example below is a new integration plugin, created from an example template, placed at the settings integration.
The example below is a new integration plugins configuration, created from an example template, placed at the settings integrations config.
The example below is a creating new example integration using the form.
The example below is a creating new integration with detail page. If you choose with detail choice in integration plugin UI template. You will link to detail page when clicking add button.
API file structure
After creating an integration plugin, the following files are generated automatically in your integration plugin API.
📦plugin-twitter-api
┣ 📂src
┃ ┣ 📂graphql
┃ ┃ ┣ 📂resolvers
┃ ┃ ┃ ┣ index.ts
┃ ┃ ┃ ┣ mutations.ts
┃ ┃ ┃ ┗ queries.ts
┃ ┃ ┣ index.ts
┃ ┃ ┗ typeDefs.ts
┃ ┣ configs.ts
┃ ┣ controller.ts
┃ ┣ messageBroker.ts
┃ ┗ models.ts
┣ .env.sample
┣ package.json
┗ tsconfig.json
Main files
Following files are generated automatically in plugin-[pluginName]-api/src.
configs.ts
This file contains main configuration of an integration plugin.
configs.ts file:
// path: ./packages/plugin-[pluginName]-api/src/configs.ts
import typeDefs from './graphql/typeDefs';
import resolvers from './graphql/resolvers';
import { initBroker } from './messageBroker';
import init from './controller';
export let mainDb;
export let graphqlPubsub;
export let serviceDiscovery;
export let debug;
export default {
name: 'twitter',
graphql: sd => {
serviceDiscovery = sd;
return {
typeDefs,
resolvers
};
},
meta: {
// this code will show the integration in UI settings -> integrations
inboxIntegration: {
kind: 'twitter',
label: 'Twitter'
}
},
apolloServerContext: async (context) => {
return context;
},
onServerInit: async options => {
const app = options.app;
mainDb = options.db;
debug = options.debug;
graphqlPubsub = options.pubsubClient;
initBroker(options.messageBrokerClient);
// integration controller
init(app);
}
};
controller.ts
This file contains integration controllers such as listening integration, connecting integration with erxes, and create conversation and customer. In this example shows message savings.
controller.ts file:
import { sendContactsMessage, sendInboxMessage } from './messageBroker';
import { Customers, Messages } from './models';
// util function
const searchMessages = (linkedin, criteria) => {
return new Promise((resolve, reject) => {
const messages: any = [];
});
};
// Example for save messages to inbox and create or update customer
const saveMessages = async (
linkedin,
integration,
criteria
) => {
const msgs: any = await searchMessages(linkedin, criteria);
for (const msg of msgs) {
const message = await Messages.findOne({
messageId: msg.messageId
});
if (message) {
continue;
}
const from = msg.from.value[0].address;
const prev = await Customers.findOne({ email: from });
let customerId;
if (!prev) {
// search customer from contacts api
const customer = await sendContactsMessage({
subdomain: 'os',
action: 'customers.findOne',
data: {
primaryEmail: from
},
isRPC: true
});
if (customer) {
customerId = customer._id;
} else {
// creating new customer
const apiCustomerResponse = await sendContactsMessage({
subdomain: 'os',
action: 'customers.createCustomer',
data: {
integrationId: integration.inboxId,
primaryEmail: from
},
isRPC: true
});
customerId = apiCustomerResponse._id;
}
await Customers.create({
inboxIntegrationId: integration.inboxId,
contactsId: customerId,
email: from
});
} else {
customerId = prev.contactsId;
}
let conversationId;
const relatedMessage = await Messages.findOne({
$or: [
{ messageId: msg.inReplyTo },
{ messageId: { $in: msg.references || [] } },
{ references: { $in: [msg.messageId] } },
{ references: { $in: [msg.inReplyTo] } }
]
});
if (relatedMessage) {
conversationId = relatedMessage.inboxConversationId;
} else {
const { _id } = await sendInboxMessage({
subdomain: 'os',
action: 'integrations.receive',
data: {
action: 'create-or-update-conversation',
payload: JSON.stringify({
integrationId: integration.inboxId,
customerId,
createdAt: msg.date,
content: msg.subject
})
},
isRPC: true
});
conversationId = _id;
}
await Messages.create({
inboxIntegrationId: integration.inboxId,
inboxConversationId: conversationId,
createdAt: msg.date,
messageId: msg.messageId,
inReplyTo: msg.inReplyTo,
references: msg.references,
subject: msg.subject,
body: msg.html,
to: msg.to && msg.to.value,
cc: msg.cc && msg.cc.value,
bcc: msg.bcc && msg.bcc.value,
from: msg.from && msg.from.value,
});
}
};
// controller for twitter
const init = async app => {
// write integration login method below
app.get('/login', async (req, res) => {
res.send("login")
});
app.post('/receive', async (req, res, next) => {
try {
// write receive message from integration code here
res.send("Successfully receiving message");
} catch (e) {
return next(new Error(e));
}
res.sendStatus(200);
});
};
export default init;
messageBroker.ts
This file uses for connect with other plugins. You can see message broker functions from Common functions.
messageBroker.ts file:
// path: ./packages/plugin-[pluginName]-api/src/messageBroker.ts
import * as dotenv from 'dotenv';
import {
ISendMessageArgs,
sendMessage as sendCommonMessage
} from '@erxes/api-utils/src/core';
import { serviceDiscovery } from './configs';
import { Customers, Integrations, Messages } from './models';
dotenv.config();
let client;
export const initBroker = async cl => {
client = cl;
const { consumeRPCQueue } = client;
consumeRPCQueue(
'twitter:createIntegration',
async ({ data: { doc, integrationId } }) => {
await Integrations.create({
inboxId: integrationId,
...(doc || {})
});
return {
status: 'success'
};
}
);
consumeRPCQueue(
'twitter:removeIntegration',
async ({ data: { integrationId } }) => {
await Messages.remove({ inboxIntegrationId: integrationId });
await Customers.remove({ inboxIntegrationId: integrationId });
await Integrations.remove({ inboxId: integrationId });
return {
status: 'success'
};
}
);
};
export default function() {
return client;
}
export const sendContactsMessage = (args: ISendMessageArgs) => {
return sendCommonMessage({
client,
serviceDiscovery,
serviceName: 'contacts',
...args
});
};
export const sendInboxMessage = (args: ISendMessageArgs) => {
return sendCommonMessage({
client,
serviceDiscovery,
serviceName: 'inbox',
...args
});
};
GraphQL development
Inside packages/plugin-<new_plugin>-api/src
, we have a graphql
folder. The folder contains code related to GraphQL.
📂src
┣ 📂graphql
┃ ┣ 📂resolvers
┃ ┃ ┣ index.ts
┃ ┃ ┣ mutations.ts
┃ ┃ ┗ queries.ts
┃ ┣ index.ts
┃ ┗ typeDefs.ts
GraphQL resolvers
Inside /graphql/resolvers/mutations
GraphQL mutation codes.
mutation examples:
import { Accounts } from '../../models';
import { IContext } from "@erxes/api-utils/src/types"
const twitterMutations = {
async twitterAccountRemove(_root, {_id}: {_id: string}, _context: IContext) {
await Accounts.removeAccount(_id);
return 'deleted';
}
};
export default twitterMutations;
Inside /graphql/resolvers/queries
folder contains GraphQL query codes.
query examples:
import { IContext } from '@erxes/api-utils/src/types';
import { Accounts, Messages } from '../../models';
const queries = {
async twitterConversationDetail(
_root,
{ conversationId },
_context: IContext
) {
const messages = await Messages.find({
inboxConversationId: conversationId
});
const convertEmails = emails =>
(emails || []).map(item => ({ name: item.name, email: item.address }));
return messages.map(message => {
return {
_id: message._id,
mailData: {
messageId: message.messageId,
from: convertEmails(message.from),
to: convertEmails(message.to),
cc: convertEmails(message.cc),
bcc: convertEmails(message.bcc),
subject: message.subject,
body: message.body,
}
};
});
},
async twitterAccounts(_root, _args, _context: IContext) {
return Accounts.getAccounts();
}
};
export default queries;
GraphQL typeDefs
Inside /graphql/typeDefs.ts
file contains GraphQL typeDefs.
typeDefs:
import { gql } from 'apollo-server-express';
const types = `
type Twitter {
_id: String!
title: String
mailData: JSON
}
`;
const queries = `
twitterConversationDetail(conversationId: String!): [Twitter]
twitterAccounts: JSON
`;
const mutations = `
twitterAccountRemove(_id: String!): String
`;
const typeDefs = gql`
scalar JSON
scalar Date
${types}
extend type Query {
${queries}
}
extend type Mutation {
${mutations}
}
`;
export default typeDefs;
Database development
Inside packages/plugin-<new_plugin>-api/src
, we have a models
file. The file contains code related to MongoDB and mongoose.
📂src
┗ models.ts
Mongoose schema and model
Inside src/models.ts
, file contains Mongoose schema and models.
Mongoose schema and model example:
1
UI file structure
Automatically generated integration plugin UI's file structure same as general plugin. Only difference is configuring UI. If you want to see general plugin UI file structure click here.
Configuring UI
Running port for plugin
Inside packages/plugin-<new_plugin>-ui/src/configs.js
, running port for plugin UI is set as shown below. Default value is 3024. Please note that each plugin has to have its UI running on an unique port. You may need to change the port manually (inside configs.js
) if developing multiple plugins.
module.exports = {
name: 'twitter',
scope: 'twitter',
port: 3024,
exposes: {
'./routes': './src/routes.tsx',
// below components will work dynamically. In our case we call this components in inbox-ui.
'./inboxIntegrationSettings': './src/components/IntegrationSettings.tsx',
'./inboxIntegrationForm': './src/components/IntegrationForm.tsx',
'./inboxConversationDetail': './src/components/ConversationDetail.tsx'
},
routes: {
url: 'http://localhost:3024/remoteEntry.js',
scope: 'twitter',
module: './routes'
},
// calling exposed components
inboxIntegrationSettings: './inboxIntegrationSettings',
inboxIntegrationForm: './inboxIntegrationForm',
inboxConversationDetail: './inboxConversationDetail',
inboxIntegration: {
name: 'Twitter',
// integration desciption will show in integration box.
description:
'Please write integration description on plugin config file',
// this variable shows that integration available in production mode. In development mode we always set true value.
isAvailable: true,
// integration kind will useful for call integration form dynamically
kind: 'twitter',
// integration logo
logo: '/images/integrations/twitter.png',
// if you choose with detail page when creating an integration plugin using cli. When you click add button in integration box, integration will link to this url.
createUrl: '/settings/integrations/twitter',
}
};
Plugins dynamic components
Files where exposed components are called dynamically in plugin inbox UI.
inboxIntegrationSettings.tsx component
Inside packages/plugin-inbox-ui/src/settings/integrationsConfig/components/IntegrationConfigs.tsx
, we have a loadDynamicComponent
function. The code below shows the integrationConfigs component being called dynamically in the plugin inbox UI.
{loadDynamicComponent(
'inboxIntegrationSettings',
{
renderItem: this.renderItem
},
true
)}
inboxIntegrationForm.tsx component
Inside packages/plugin-inbox-ui/src/settings/integrations/containers/common/IntegrationForm.tsx
, we have a loadDynamicComponent
function. The code below shows the integrationForm component being called dynamically in the plugin inbox UI.
return loadDynamicComponent(
'inboxIntegrationForm',
updatedProps,
false,
type
);
inboxConversationDetail.tsx component
Inside packages/plugin-inbox-ui/src/inbox/components/conversationDetail/workarea/WorkArea.tsx
, we have a loadDynamicComponent
function. The code below shows the inboxConversationDetail component being called dynamically in the plugin inbox UI.
content = loadDynamicComponent('inboxConversationDetail', {
...this.props
});
Enabling plugins
"plugins" section inside cli/configs.json
contains plugin names that run when erxes starts. Please note to configure this section if you decide to enable other plugins, remove or recreate plugins.
{
"jwt_token_secret": "token",
"dashboard": {},
"client_portal_domains": "",
"elasticsearch": {},
"redis": {
"password": ""
},
"mongo": {
"username": "",
"password": ""
},
"rabbitmq": {
"cookie": "",
"user": "",
"pass": "",
"vhost": ""
},
"plugins": [
{
"name": "logs"
},
{
"name": "new_plugin",
"ui": "local"
}
]
}
Running erxes
Please note that create-plugin
command automatically adds a new line inside cli/configs.json
, as well as installs the dependencies necessary.
{
"jwt_token_secret": "token",
"client_portal_domains": "",
"elasticsearch": {},
"redis": {
"password": "pass"
},
"mongo": {
"username": "",
"password": ""
},
"rabbitmq": {
"cookie": "",
"user": "",
"pass": "",
"vhost": ""
},
"plugins": [
{
"name": "forms",
"ui": "remote"
},
{
"name": "contacts",
"ui": "local"
},
{
"name": "inbox",
"ui": "local"
},
{
"name": "twitter",
"ui": "local"
},
]
}
- Run the following command
cd erxes/cli
yarn install
- Then run the following command to start erxes with your newly installed plugin
./bin/erxes.js dev