Skip to content

Hello World: Creating Your First React + GraphQL Custom Card for HubSpot’s CRM

UI Extensions are one of the newest and most powerful additions for customizing your HubSpot CRM. With UI Extensions you are able to customize your CRM record UI to fit the needs of your business and allow end users to quickly access both HubSpot data and external data from within and outside of your account. In this post, we are going to create a simple “Fulfillment” card that utilizes the React library along with GraphQL to pull Custom Object data related to our CRM records so we can view it all in one place.  We have hosted all the code that we will be using in a repo on GitHub for your reference.

Note: This article has been updated to reflect the most recent update in the developer projects platform version 2023.2

Our use case

We’ll be looking at this from the viewpoint of a school supply kit manufacturer who ships products on a yearly cadence and has a sales team that needs to be able to:

  • View information on past shipments to schools  (tracked as companies in the HubSpot CRM).
  • View information on current shipments.
  • Drill down into a shipment to see the current status of the line items (kits) in our warehouse.

Setting up our environment

 

Before we begin creating our custom card, we need to make sure we have the proper setup for coding, testing, and deploying. We have a detailed setup guide that you can follow along with to get your environment setup. We won’t reiterate everything that is in there so feel free to open the link above in a new tab and then come on back when you’re ready to begin. Below is a quick summary of what we’ll need for setup.

  1. A HubSpot Account with either a Sales Enterprise or Service Enterprise subscription.
    • Using a sandbox account for testing is optional but strongly recommended. It’s also a safe way to interact with data without causing potential conflicts or issues in a production environment.
  2. A code editor such as VSCode coupled with our official HubSpot extension.
  3. Installing Node.js - an open-source, cross-platform JavaScript runtime environment which enables HubSpot’s local development tooling.
  4. Installing the HubSpot CLI - Our command line interface that allows you to connect and interact with your HubSpot account in local development tooling.

Once we have the above items ready to go, let’s connect our HubSpot account using the CLI by going into a new terminal window within VSCode (this can be done by going to the Terminal > New Terminal menu option) and running the hs init command which will prompt us to open HubSpot, choose our portal and bring us to a screen for our Personal Access Key (PAK) that is required in order for us to interact with our account. 

Using our 'hs init' command

When creating your PAK, you will need to make sure it includes the following scopes:

  • developer.projects.write
  • developer.app_functions.read and developer.app_functions.write
  • sandboxes.write (required if you have chosen to use a standard sandbox for development)

Note: If you have an existing PAK, and it does not include the scopes above. You will need to deactivate your current PAK and regenerate a new one with the required scopes

With our PAK now created (or updated), let's go back to our VSCode terminal and paste our PAK into the terminal and press enter/return. You'll be prompted to enter a unique name for your account. Make sure to choose a name that will help identify your account as you are working (this is especially helpful when working with multiple accounts). Enter your unique name and press enter/return. You should now see a hubspot.config.yaml file created that contains all the information for working with our account. Below is an example of a config file with sensitive information redacted.

Preview of our hubspot.config.yaml

Important Note: If you are using a versioning system (another thing we strongly recommend) such as GitHub, make sure to add this file to your .gitignore file as not to expose this information and cause security concerns. To learn more about using GitHub, I recommend looking at the GitHub's own quickstart guide.

If you are working with multiple HubSpot accounts locally, you can use the hs auth command to connect them to your local tooling and set a default account using the hs accounts use [accountName] command where [accountName] is the name you have assigned to the account above.

Creating our Project for our Custom Card

With our environment all setup and our HubSpot Account connected using our CLI, we can begin creating our first project for our custom card. Let’s go back to our terminal in VS Code and enter the following command hs project create. We will now be prompted for the following information:

  • [--name] Give your project a name: For this, we will use Fulfillment Card.
  • [--location] Where should the project be created? We’ll keep the location that is provided for us by default. 
  • [--template] Start from a template? Let’s choose the Use CRM getting started project option.

Running our 'hs project create' command

The getting started project files will then be added to our chosen location. Your folder structured should look like the following:

Example of what the directory should look like

Renaming a few files for readability and naming conventions

Now, let’s start by renaming some of the files to something that will be able to better express what the files are intended to do. Rename the following files as such:

  • Fulfillment Card/src/app/app.functions/example-function.js -> fetchAssociatedShipments.js
    This is our functions file where our code (both JS and GraphQL) will be contained for grabbing our shipments and kits data to be displayed in the custom card.
  • Fulfillment Card/src/app/extensions/example-card.json -> fulfillment-card.json
    This is the informational file that will tell HubSpot the name of the custom card to be displayed as well as the location of the custom card and what file we will use for the custom cards content.
  • Fulfillment Card/src/app/extensions/Example.jsx -> card-frontend.jsx
    This is the file that contains the code for the custom card that users will see.

To get a full breakdown of what these files do along with descriptions of their properties, please review the create an extension page under the extensions section of the CRM Developer tab.

Updating our app.json configuration file to include the proper scopes for our project

When working with our custom card, it’s important that we assign the appropriate scopes for the app that will be installed with it. By default, we include some details in this file, but for our needs, we will rename some things and add more scopes. It’s important to make sure you are adding a unique uid value. This helps to prevent issues in the event you rename your application or update its description. Below is what our app.json info should look like. 

{ "name": "Fulfillment Card", "description": "This app powers a custom card for shipping fulfillment operations at a company.", "scopes": [ "crm.objects.companies.read", "crm.objects.companies.write", "collector.graphql_schema.read", "collector.graphql_query.execute", "crm.objects.custom.write", "crm.objects.custom.read", "crm.schemas.custom.read", "crm.schemas.custom.write" ], "uid" : "fulfillment_card_app", "public": false, "extensions": { "crm": { "cards": [ { "file": "extensions/fulfillment-card.json" } ] } } }

Updating our serverless.json file to point to the correct file for our appFunctions

 

Our serverless.json file is our configuration file for our serverless function that powers our custom card. Let’s update ours to rename the myFunc function to something that reflects its use (remember, we want to use descriptive naming conventions) and update the example-function.js value to reference the rename of that file we did earlier. This should have our serverless.json file looking like this:

{ "appFunctions": { "fetchAssociatedShipments": { "file": "fetchAssociatedShipments.js", "secrets": [] } } }

Updating our fulfillment-card.json configuration

The last thing we want to do before we upload our project is update the information that is within our fulfillment-card.json configuration file. This information tells HubSpot what the name of the custom card should be on the front end when displayed, where the custom card should be located in the record tab, and what file should be referenced for displaying the custom card on the frontend. We will also want to make sure to change the uid in the file to something that reflects the custom card. Similar to its usage in the app.json configuration file, the uid in our custom card JSON is added in order to prevent issues if we were to ever update details of the custom card later on. Below is the final contents of our file:

{ "type": "crm-card", "data": { "title": "FulFillment", "uid": "fulfillment_card", "location": "crm.record.tab", "module": { "file": "card-frontend.jsx" }, "objectTypes": [ { "name": "companies" } ] } }

Uploading our project to our account

With our files renamed, our app/serverless configurations done, and our fulfillment-card.json information updated, we can now upload our project using our CLI by using the hs project upload command. We should see the status of the build happening within our terminal and once completed, we will see a success message. When running this command, make sure you are in the directory that houses your hsproject.json file. In our example, this would be the Fulfillment Card folder. Once you run this command, you will be prompted with a message that says:

“The project [Your Project Name] does not exist in [Account Name} ([Account ID Num]). Would you like to create it?”

We will select "Y" and continue. You should then see your custom card build and be deployed on your account.

Running our 'hs project upload' command

You can verify both your custom card and your app have been deployed by going to your projects area and private apps area respectively within the CRM Development Tab.

Verifying our project and app were created

To check that our custom card is showing correctly, go ahead and navigate to the companies area under your account and open up a company record then go to the “Custom” tab. You should see the custom card loaded with some sample information that we currently have in our files.

Preview of custom card on company record with example data

Creating our Custom Objects

 

Because our custom card will be interacting with a custom object, we will need to create the custom object before we begin working on our custom card. There are two ways to create your custom objects: either within your account settings or through our Custom Objects API. If you are using the API, it will require you to also use/create a private app for authentication. Whichever method you choose is completely up to you. It's always best to plan out your custom objects for scalability. We have a great resource on our blog that we highly recommend reading.

For this article, we will be using the API method to create our custom objects and using Postman as our tool to make the calls. 

To make this portion of the article easier, we have provided a collection with the information for this example and you can access them by using the “Run in Postman” button below.

 
  1. Click the + icon in the upper left of Postman that is located to the right of the “Collections” option.
  2. With our new collection now open, change the name to something we can use to identify our project. For our article, we will call it “Hello World: Fulfillment Card”.

Creating a new collection in postman

Now we’ll setup our Postman collection to always use our Apps access token when making calls. 

  1. Go to the Authorization tab and choose Bearer Token we will be adding the token from our Application we created above in order make our API calls.
  2. Go to your HubSpot Account and the Private Apps area.
  3. Click on your newly created Private App and go to the Authorization tab.
  4. Under the Access token section, click Show token, then click Copy.
  5. Now go back to Postman and paste your key into the Token field then save your changes.

Adding our app's access token to postman

We are now ready to create our custom objects!

Creating our Kit(s) custom objects

 

Our kits object will hold line item level data for our shipments. We are going to create this object first as the “shipments” object (which we will be creating next) will be associated with this object. Inside of postman, let’s create a POST request to the /crm/v3/schemas API endpoint. Not familiar with API endpoints? We recommend checking out this article about them.

  1. Click the Add a request text in our left panel under our new collection. This will open a new request tab for us.
  2. Let’s rename our request to reflect it’s intended use. We’ll call ours "Create Custom Object Schema".
  3. Click the dropdown under the request name where it says GET and change this to the POST method.
  4. To the right of this dropdown, we will add the url to the create custom object schema API endpoint: https://api.hubapi.com/crm/v3/schemas
  5. Now, click on the Body tab under our endpoint and choose the “raw” radio button. A new dropdown will appear after the button group. Change this dropdown from “Text” to “JSON” and save the request.
  6. Copy the JSON listed below and paste this into the provided textarea inside of postman for our request.
    { "name": "kits", "labels": { "singular": "Kit", "plural": "Kits" }, "requiredProperties": ["kit_number"], "searchableProperties": ["year", "kit_number"], "primaryDisplayProperty": "kit_number", "secondaryDisplayProperties": ["year"], "metaType": "PORTAL_SPECIFIC", "properties": [ { "name": "hold_reason", "label": "Hold Reason", "type": "string", "fieldType": "textarea", "description": "Reasoning why kit is put on hold" }, { "name": "status", "label": "Status", "type": "enumeration", "fieldType": "select", "description": "Current build status of kit", "options": [ { "label": "Not Started", "value": "not_started", "displayOrder": 0, "hidden": false }, { "label": "In Progress", "value": "in_progress", "displayOrder": 1, "hidden": false }, { "label": "On Hold", "value": "on_hold", "displayOrder": 2, "hidden": false }, { "label": "Complete", "value": "complete", "displayOrder": 3, "hidden": false } ] }, { "name": "year", "label": "Year", "type": "number", "fieldType": "number", "description": "Year kit was created" }, { "name": "kit_number", "label": "Kit Number", "type": "string", "fieldType": "text", "description": "Number that identifies each kit" } ] }
  7. Click the Send button to execute our request. Within the success response, make sure to document and make note of our Kit/s objectTypeId property and the value that is returned, we will make use of this value in the next section to associate our Shipment/s and Kit/s objects. For our article, this value is 2-17958009.

With the code above, we have also created the following properties along with our custom object:

  • Kit Number - Primary display property for our kits that identifies each kit.
  • Year  - Represents the year for which the kit was created.
  • Status - The current build status of the kit.
  • Hold Reason - If our status ever shows a kit is on hold, this will provide the reasoning why.

We can verify our Kit(s) object was created successfully by going to the custom objects area within our account.

Verifying our 'Kits' custom object was created

Shipment(s) custom object

Our Shipment(s) object will hold top level information on the main shipment that is sent to Schools (companies). This step is a bit easier as we did a lot of the setup work in the previous objects creation. For this, we just simply need to replace the Body JSON from the previous call with the JSON below and click the Send button again.

Take note of the associatedObjects array in the code on line 12. This is where we have added the objectTypeId property that was assigned to our Kit(s) object from the previous section.

{ "name": "shipments", "labels": { "singular": "shipment", "plural": "Shipments" }, "requiredProperties": ["order_num"], "searchableProperties": ["description", "order_num"], "primaryDisplayProperty": "order_num", "secondaryDisplayProperties": ["description"], "metaType": "PORTAL_SPECIFIC", "associatedObjects": ["COMPANY", "2-17958009"], "properties": [ { "name": "carrier", "label": "Carrier", "type": "enumeration", "fieldType": "select", "description": "Carrier responsible for transportation", "options": [ { "label": "FedEx Freight", "value": "fedex_freight", "displayOrder": 0, "hidden": false }, { "label": "UPS Ground", "value": "ups_ground", "displayOrder": 1, "hidden": false }, { "label": "FedEx", "value": "fedex", "displayOrder": 2, "hidden": false }, { "label": "UPS", "value": "ups", "displayOrder": 3, "hidden": false } ] }, { "name": "status", "label": "Status", "type": "enumeration", "fieldType": "select", "description": "Status of shipment", "options": [ { "label": "Not Started", "value": "not_started", "displayOrder": 0, "hidden": false }, { "label": "In Progress", "value": "in_progress", "displayOrder": 1, "hidden": false }, { "label": "Fulfillment Complete", "value": "fulfillment_complete", "displayOrder": 2, "hidden": false }, { "label": "Staged for Shipping", "value": "staged_for_shipping", "displayOrder": 3, "hidden": false }, { "label": "Shipped", "value": "shipped", "displayOrder": 4, "hidden": false }, { "label": "Delivered", "value": "delivered", "displayOrder": 5, "hidden": false } ] }, { "name": "tracking_num", "label": "Tracking Num", "type": "string", "fieldType": "text", "description": "Tracking number associated to the shipment" }, { "name": "year", "label": "Year", "type": "number", "fieldType": "number", "description": "Year shipment was made" }, { "name": "description", "label": "Description", "type": "string", "fieldType": "text", "description": "Description of the shipment" }, { "name": "order_num", "label": "Order Num", "type": "string", "fieldType": "text", "description": "Number that identifies each shipment" } ] }

With the code above, we have also created the following properties along with our custom object:

  • Order Num - Primary display property for our shipments that identifies each shipment.
  • Description - Describes the shipment.
  • Year  - Represents the year for which the shipment was made.
  • Status - The current build status of the shipment.
  • Carrier - The carrier that is responsible for transporting the shipment.
  • Tracking Number - The tracking number associated with the shipment.

Returning back to our custom objects area inside of our account, we can now see the Shipment(s) object has been created:

Verifying our 'Shipments' custom object was created

Reviewing our custom object’s associations

 

With the creation of our Kits and Shipments objects complete, we can review the overall associations within our Data Model overview to verify they have been setup correctly. This tool is very helpful when you are trying to see how all of your data is connected with your HubSpot account. To learn more about this, see our knowledge base article.

Data model overview showing our relationships

Creating sample data for our custom objects to associate and display

 

The last step in creating our custom objects is to populate them with some sample data. There are multiple methods to do this. We recommend using our imports tool, we have prepared a few sample files below with data for your use. You may also choose to manually create these records for our objects and associate them yourself.

Sample Files:

Building our React custom card

Earlier, when we created our project through the CLI and chose the “CRM getting started project", this provided us with both an example custom card and function that we renamed in order to better convey their uses. We are going to be modifying these files to customize the current card to reference the data we just imported into our Custom Objects.

Creating our serverless function to grab our data

 

We’re going to begin by first creating our serverless function that will be grabbing the data we need as this will aid in building our custom cards frontend. Go ahead and open the fetchAssociatedShipments.js file from earlier. You should see a sample exports.main function inside, let’s start by modifying this to our needs.

Want to skip ahead? Jump right to the full code for our fetchAssociatedShipments.js at the end of this section.

We’ll begin by replacing our sample code with the following:

const axios = require('axios'); exports.main = async (context = {}) => { const { hs_object_id } = context.parameters; const PRIVATE_APP_TOKEN = process.env.PRIVATE_APP_ACCESS_TOKEN; try { const { data } = await fetchAssociatedShipments( query, PRIVATE_APP_TOKEN, hs_object_id ); return(data); } catch (e) { return(e); } };

In this code, we start by requiring axios, which is a JavaScript library used for making HTTP requests. Then, within our main function, we are setting two constant variables to values that are pulled from the parameters we are passing in. These values are our object’s id and the access token from our private app. After that we use a try…catch statement in JavaScript to set another variable equal to the data that is pulled back (our return) from a fetchAssociatedShipments function that we will add next. If an exception is thrown during this process, we have the error sent back instead. 

Under our exports.main function, let’s declare our fetchAssociatedShipments function. This function will make an HTTP request (using axios) to our GraphQL API endpoint with parameters attached and pass HTTP headers that convey what type of content we are sending in our request body while providing our authorization which uses our private app’s access token. Below is our code for this:

//[... this is where our axios and exports.main function are ...] const fetchAssociatedShipments = (query, token, hs_object_id) => { const body = { operationName: 'shipmentData', query, variables: { hs_object_id } }; return axios.post( 'https://api.hubapi.com/collector/graphql', JSON.stringify(body), { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); };

Last, we are going define our query in a constant underneath our previous function. The easiest path to creating this query is to utilize the GraphiQL explorer within your account. You can use this tool to test your query in order to validate it’s returning the correct values then copy and paste it into your code. Below is a sample of our query in the GraphiQL explorer.

GraphiQL explorer showing our query

Notice that we have added a query variable (hs_object_id) and set this to a static value within GraphiQL that is equal to that of one of our companies. This is to ensure we are able to pull back data when a companies object id is passed along. You can find the id of a company by browsing to their record in the CRM and using the strong of numbers in the URL.

Location of company record's object ID

 Let’s now add the code from our GraphiQL query to our file:

//[... this is where our axios, exports.main function, // and fetchAssociatedShipments function are ...] const query = ` query shipmentData($hs_object_id: String!) { CRM { company(uniqueIdentifier: "hs_object_id", uniqueIdentifierValue: $hs_object_id) { associations { p_shipments_collection__shipments_to_company { items { hs_object_id year order_num description status carrier tracking_num associations { p_kits_collection__shipments_to_kits { items { year kit_number status hold_reason } } } } } } } } } `

With our servless function now completed, our final fetchAssociatedShipments.js file should reflect the contents below. We have provided comments inside of this code to reiterate what each part does.

Final fetchAssociatedShipments JS file

 

//fetchAssociatedShipments.js File Final Contents const axios = require('axios'); exports.main = async (context = {}) => { // const's are set by parameters that were passed in and from our secrets const { hs_object_id } = context.parameters; const PRIVATE_APP_TOKEN = process.env.PRIVATE_APP_ACCESS_TOKEN; try { // Fetch associated shipments and assign to a const const { data } = await fetchAssociatedShipments( query, PRIVATE_APP_TOKEN, hs_object_id ); // Send the response data return(data); } catch (e) { return(e); } }; const fetchAssociatedShipments = (query, token, hs_object_id) => { // Set our body for the axios call const body = { operationName: 'shipmentData', query, variables: { hs_object_id } }; // return the axios post return axios.post( 'https://api.hubapi.com/collector/graphql', JSON.stringify(body), { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); }; // GraphQL query to fetch shipment associations and nested kit associations from HubSpot const query = ` query shipmentData($hs_object_id: String!) { CRM { company(uniqueIdentifier: "hs_object_id", uniqueIdentifierValue: $hs_object_id) { associations { p_shipments_collection__shipments_to_company { items { hs_object_id year order_num description status carrier tracking_num associations { p_kits_collection__shipments_to_kits { items { year kit_number status hold_reason } } } } } } } } } `

We can now move onto creating the frontend of the custom card.

Creating our custom cards frontend

Let’s open our card-frontend.jsx file and clear everything inside of it so we can start anew with creating our custom card.

Want to skip ahead? Jump right to the full code for our card-frontend.jsx at the end of this section.

We are going to start our file by importing React as well as the useEffect and useState React hooks into our custom card. Then, we will import the HubSpot components we will be using within our custom card. Go ahead and add the following to the card-frontend.jsx file.

import React, { useEffect, useState } from "react"; import { Button, Text, Flex, Tag, hubspot, LoadingSpinner, TableBody, TableCell, TableHead, TableRow, TableHeader, Table, Heading, Link } from "@hubspot/ui-extensions";

Next, we will define our extension to be run within the HubSpot CRM using the following code.

//[... this is where our imports are ...] hubspot.extend(({ context, runServerlessFunction, actions }) => <Extension context={context} runServerless={runServerlessFunction} fetchProperties={actions.fetchCrmObjectProperties} /> );

The hubspot.extend() function takes an object that contains three parameters. It then returns an <Extension /> React component that has the values for context, runServerless, and CRM properties method.

Next, we define our Extension component taking in context, runServerless, and fetchCRMObjectProperties as props:

//[... this is where our imports and hubspot.extend function are ...] const Extension = ({ context, runServerless, fetchProperties }) => { }; // end Extension()

Within the Extension component, we will create constant variables that will hold data throughout our custom card’s functionality. We will also set useState’s for each one of these constants initializations. We have provided comments below to explain what each variable represents.

//[... this is where our imports and hubspot.extend function are ...] const Extension = ({ context, runServerless, fetchProperties }) => { //This will hold our "Shipments" data from our GraphQL query in fetchAssociatedOrders.js. const [shipments, setShipments] = useState(null); //Boolean - sets visibility of showing details screen. const [showShipmentDetails, setShowShipmentDetails] = useState(false); // Holds our "Order Number" for our "Shipment" that is then shown when viewing the details of the shipment. const [shipmentDetailsOrderNumber, setShipmentDetailsOrderNumber] = useState(null); // Holds the "hs_object_id" of the shipment that is used to filter the right shipment when viewing details of the shipment. const [shipmentHSObjectId, setShipmentHSObjectId] = useState(null); // Holds the "hs_object_id" of the current company being viewed. This is then used as parameter in our GraphQL query to get the correct companies associations. const [currentObjectID, setCurrentObjectID] = useState(null); //Boolean - defines loading state of the content. const [loading, setLoading] = useState(true); }; // end Extension()

After we have defined our constants, we will create a useEffect hook underneath them that contains a dependency of the currentObjectId. When this value changes, it will cause the hook to run again using the new value. Let’s add the following:

//[... this is where our imports and hubspot.extend function are ...] const Extension = ({ context, runServerless, fetchProperties }) => { //[... this is where constant variables are here ...] useEffect(() => { fetchProperties(["hs_object_id"]).then((properties) => { setCurrentObjectID(properties.hs_object_id); }); runServerless({ name: "fetchAssociatedShipments", parameters: { hs_object_id: currentObjectID } }).then((resp) => { if (resp.status === "SUCCESS") { setShipments(resp.response.data.CRM.company.associations.p_shipments_collection__shipments_to_company.items); setLoading(false); } }); }, [currentObjectID]); }; // end Extension()

Within our hook, we are fetching the hs_object_id of our object (the company object) in which the custom card is going to be running from. We are then setting this id to our currentObjectID variable and then we are telling the custom card to run our fetchAssociatedShipments code from the file we created in the previous section and pass our object’s ID to that file. Next, when we get a response from the file, we are then setting the shipments variable to the returned items from the GraphQL query. Lastly, we are setting our loading variable to false which we'll use for communicating to the user when data is loading.

We will now create three functions underneath our hook. These functions are:

  • openShipmentDetails: This will run when someone decides to view details of a shipment
  • renderTag: This will control the color of the tags to be shown to users for statuses
  • renderShipmentDetails: This filters the “Shipment” details and renders the associated kit data. Note, we are using the <TableRow> components with this data as it will be nested inside of a <Table> component on our screens (more on those next).

Add the following code after the useEffect hook:

//[... this is where our imports and hubspot.extend function are ...] const Extension = ({ context, runServerless, fetchProperties }) => { //[... this is where constant variables and useEffect hook are here ...] const openShipmentDetails = (hs_object_id, order_num) => { setShipmentHSObjectId(hs_object_id); setShipmentDetailsOrderNumber(order_num); setShowShipmentDetails(true); }; const renderTag = (status) => { if (status == "complete" || status == "delivered") { return "success"; } else if (status == "on_hold") { return "warning"; } else { return "default"; } }; const renderShipmentDetails = () => { const filteredShipmentDetails = shipments.filter((filteredShipment) => filteredShipment.hs_object_id == shipmentHSObjectId); return filteredShipmentDetails.map((filteredItem) => filteredItem.associations.p_kits_collection__shipments_to_kits.items.map((kit) => ( <TableRow> <TableCell>{kit.year}</TableCell> <TableCell>{kit.kit_number}</TableCell> <TableCell> <Tag variant={renderTag(kit.status.value)}>{kit.status.label}</Tag> </TableCell> <TableCell>{kit.hold_reason}</TableCell> </TableRow> )) ); }; }; // end Extension()

With our imports, hubspot.extend function, useEffect hook, and other functions added, we can now begin working on our screens. As we mentioned screens are simply the different portions of the custom card that will be viewed.

We will have two screens in our custom card:

  • Main View (shipments): Where a user can see all shipments for a company
  • Shipment Details screen: Where a user can view the details of a specific shipment

We’ll begin by adding the following code for our Shipment Details screen underneath our previous functions.

//[... this is where our imports and hubspot.extend function are ...] const Extension = ({ context, runServerless, fetchProperties }) => { //[... this is where constant variables, useEffect hook, and additional functions are here ...] if (showShipmentDetails) { return ( <> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Heading>{shipmentDetailsOrderNumber} Order Details</Heading> <Table bordered={true}> <TableHead> <TableRow> <TableHeader>Year</TableHeader> <TableHeader>Kit Number</TableHeader> <TableHeader>Status</TableHeader> <TableHeader>Notes</TableHeader> </TableRow> </TableHead> <TableBody> {renderShipmentDetails()} </TableBody> </Table> <Button onClick={() => { setShowShipmentDetails(false);}}> Back </Button> </Flex> </> ); } }; // end Extension()

With this, we are telling the custom card to show our screen if the showShipmentDetails boolean is true which is set when a user clicks the “View Details” button from the main shipments screen. This was set inside of our openShipmentDetails function. Once this screen is shown, we are then rendering a table and populating the contents of the table through our earlier function of renderShipmentDetails.

Lastly, we will now add code for our Main View (shipments) screen right after the previous screen:

//[... this is where our imports and hubspot.extend function are ...] const Extension = ({ context, runServerless, fetchProperties }) => { //[... this is where constant variables, useEffect hook, additional functions, and shipment details screen are here ...] return ( <> {loading && <LoadingSpinner label='Data is loading' showLabel={true} size='md' layout='centered'></LoadingSpinner>} {shipments && ( <> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Heading>Shipments</Heading> <Table bordered={true} width='auto'> <TableHead> <TableRow> <TableHeader>Year</TableHeader> <TableHeader>Order Number & Info</TableHeader> <TableHeader>Status</TableHeader> <TableHeader>Carrier/Tracking</TableHeader> <TableHeader></TableHeader> </TableRow> </TableHead> <TableBody> {shipments.map((shipment) => ( <TableRow> <TableCell>{shipment.year}</TableCell> <TableCell> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Text>#{shipment.order_num}</Text> <Text>{shipment.description}</Text> </Flex> </TableCell> <TableCell> <Tag variant={renderTag(shipment.status.value)}>{shipment.status.label}</Tag> </TableCell> <TableCell> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Text>{shipment.carrier ? shipment.carrier.label : ""}</Text> <Text>{shipment.tracking_num ? <Link href={"https://www.aftership.com/track/" + shipment.carrier.value + "/" + shipment.tracking_num}>{shipment.tracking_num}</Link> : ""}</Text> </Flex> </TableCell> <TableCell> <Link onClick={() => openShipmentDetails(shipment.hs_object_id, shipment.order_num)} variant='primary'> View Details </Link> </TableCell> </TableRow> ))} </TableBody> </Table> </Flex> </> )} </> ); }; // end Extension()

For this code, we are telling the custom card to conditionally load our loading component and our main shipments table. In react you can use the logical AND operator (&&) to direct the code to render something when the left side of the operator is true. In our example above, we are telling the custom card:

If the loading/shipments variable are true then render our components”

The rest of our code simply outputs the appropriate components and populates them with our data. Below is the final code in our file. We have provided comments inside of this code to reiterate what each part does:

Final card-frontend JSX file

 

//This custom card is intended to be used with the "Companies" objectType within the CRM as defined in the fulfillment-card.json file. // Beging by importing React and HubSpot defined react components. import React, { useEffect, useState } from "react"; import { Button, Text, Flex, Tag, hubspot, LoadingSpinner, TableBody, TableCell, TableHead, TableRow, TableHeader, Table, Heading, Link } from "@hubspot/ui-extensions"; // This code defines an extension for the HubSpot platform. The hubspot.extend() function takes an object that contains three parameters: "context", "runServerlessFunction", and "actions". It then returns an Extension React component that has the values for those 3 parameters. hubspot.extend(({ context, runServerlessFunction, actions }) => <Extension context={context} runServerless={runServerlessFunction} fetchProperties={actions.fetchCrmObjectProperties} /> ); // The Extension Component. const Extension = ({ context, runServerless, fetchProperties }) => { // creating our constants to hold data. useState allows you to track the state of the const within a functioning component. //This will hold our "Shipments" data from our GraphQL query in fetchAssociatedOrders.js. const [shipments, setShipments] = useState(null); //Boolean - sets visibility of showing details screen. const [showShipmentDetails, setShowShipmentDetails] = useState(false); // Holds our "Order Number" for our "Shipment" that is then shown when viewing the details of the shipment. const [shipmentDetailsOrderNumber, setShipmentDetailsOrderNumber] = useState(null); // Holds the "hs_object_id" of the shipment that is used to filter the right shipment when viewing details of the shipment. const [shipmentHSObjectId, setShipmentHSObjectId] = useState(null); // Holds the "hs_object_id" of the current company being viewed. This is then used as parameter in our GraphQL query to get the correct companies associations. const [currentObjectID, setCurrentObjectID] = useState(null); //Boolean - defines loading state of the content. const [loading, setLoading] = useState(true); // useEffect allows you to perfrom side effects in your components. useEffect(() => { // fetch the hs_object_id property of the company. fetchProperties(["hs_object_id"]).then((properties) => { // set the currentObjectID const == to the hs_object_id. setCurrentObjectID(properties.hs_object_id); }); // Run our serverless function "fetchAssociatedOrders" - name and location of code to run are defined in our serverless.json file. runServerless({ name: "fetchAssociatedShipments", parameters: { hs_object_id: currentObjectID } }).then((resp) => { // Log the response to the console. This will help us find the path to the items that we will define in our "shipments" const. Once you have the "shipments" const ready, you can comment this out or remove it. //console.log(resp); // if our serverless call is made successfully... if (resp.status === "SUCCESS") { // set our "shipments" const to the "items" array from our serverless function. setShipments(resp.response.data.CRM.company.associations.p_shipments_collection__shipments_to_company.items); // Set the loading state to false. This will remove our <LoadingSpinner> component once our data has been loaded and is available. setLoading(false); } }); // When currentObjectID changes the effect will run again. }, [currentObjectID]); /* * Functions */ // When the "View Details" button is clicked in the "Shipments" table. const openShipmentDetails = (hs_object_id, order_num) => { // Set the id of the current "Shipment" for use in filtering of the details as the value passed in from the button. setShipmentHSObjectId(hs_object_id); // Set the order number of the shipment for display on the details screen as the value passed in from the button. setShipmentDetailsOrderNumber(order_num); // Set visibility of the details screen to true. setShowShipmentDetails(true); }; // Sets the <Tag> component variants. const renderTag = (status) => { if (status == "complete" || status == "delivered") { return "success"; } else if (status == "on_hold") { return "warning"; } else { return "default"; } }; // Filters the "Shipment" details and renders the associated kit data for the "Shipment". const renderShipmentDetails = () => { // Filter the Shipments available based on the shipment's hs_object_id passed in from the button and assign the filtered shipment to a const. // Sample for filter from: https://upmostly.com/tutorials/react-filter-filtering-arrays-in-react-with-examples const filteredShipmentDetails = shipments.filter((filteredShipment) => filteredShipment.hs_object_id == shipmentHSObjectId); // Map the filtered shipment details array, then map the nested associated kits array. return filteredShipmentDetails.map((filteredItem) => filteredItem.associations.p_kits_collection__shipments_to_kits.items.map((kit) => ( <TableRow> <TableCell>{kit.year}</TableCell> <TableCell>{kit.kit_number}</TableCell> <TableCell> <Tag variant={renderTag(kit.status.value)}>{kit.status.label}</Tag> </TableCell> <TableCell>{kit.hold_reason}</TableCell> </TableRow> )) ); }; /* * Screens */ // If our showShipmentDetails boolean is true, show the details of the shipment. if (showShipmentDetails) { return ( <> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Heading>{shipmentDetailsOrderNumber} Order Details</Heading> <Table bordered={true}> <TableHead> <TableRow> <TableHeader>Year</TableHeader> <TableHeader>Kit Number</TableHeader> <TableHeader>Status</TableHeader> <TableHeader>Notes</TableHeader> </TableRow> </TableHead> <TableBody> {renderShipmentDetails()} </TableBody> </Table> {/* Create a button and set the visibility of the details screen to false when clicked. */} <Button onClick={() => { setShowShipmentDetails(false);}}> Back </Button> </Flex> </> ); } return ( <> {/* If our const that signifies the loading state is true, show a loading indicator. This helps prevent the user from seeing a partially loaded component while the data loads. */} {loading && <LoadingSpinner label='Data is loading' showLabel={true} size='md' layout='centered'></LoadingSpinner>} {/* Show the table if the "shipments" const contains data. */} {shipments && ( <> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Heading>Shipments</Heading> <Table bordered={true} width='auto'> <TableHead> <TableRow> <TableHeader>Year</TableHeader> <TableHeader>Order Number & Info</TableHeader> <TableHeader>Status</TableHeader> <TableHeader>Carrier/Tracking</TableHeader> <TableHeader></TableHeader> </TableRow> </TableHead> <TableBody> {/* Map the "shipments" array data. */} {shipments.map((shipment) => ( <TableRow> <TableCell>{shipment.year}</TableCell> <TableCell> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Text>#{shipment.order_num}</Text> <Text>{shipment.description}</Text> </Flex> </TableCell> <TableCell> <Tag variant={renderTag(shipment.status.value)}>{shipment.status.label}</Tag> </TableCell> {/* For Demo purposes we are simply passing the tracking number to afterships free service. */} <TableCell> <Flex direction={'column'} wrap={'wrap'} gap={'small'}> <Text>{shipment.carrier ? shipment.carrier.label : ""}</Text> <Text>{shipment.tracking_num ? <Link href={"https://www.aftership.com/track/" + shipment.carrier.value + "/" + shipment.tracking_num}>{shipment.tracking_num}</Link> : ""}</Text> </Flex> </TableCell> <TableCell> <Link onClick={() => openShipmentDetails(shipment.hs_object_id, shipment.order_num)} variant='primary'> View Details </Link> </TableCell> </TableRow> ))} </TableBody> </Table> </Flex> </> )} </> ); };

Uploading and viewing our finished project

With our files now completed. We can save our work and upload our project to our account using the hs project upload command. Once our project has been built and deployed, which you will be notified of in the CLI, we can now browse to our sample company and navigate to the “Custom” tab. We should now see our custom card load with the associated custom object data.

Example of our custom card showing Shipment main view

If we click on the “View Details” link, we should now be able to drill down into each order and see the line items associated with it:

Example of our custom card showing Shipment details view

Go forward and create something awesome!

As you can see from our example, you can create some pretty amazing custom cards within HubSpot’s CRM using the power of React + GraphQL. We encourage you to go forward and create something amazing or iterate of our example we just did. When you do, make sure to share it on your LinkedIn as we would love to see your work!