Overview and Analytics API

Nowadays, we see analytics dashboards and reporting features in almost any application. In my career as a web developer, I’ve built dozens of different dashboards from internal tools to measure application performance to customer-facing portals with interactive report builders and dynamic dashboards.

And I cannot say I always enjoyed the process. Several years ago I was rendering all the HTML, including dashboards and charts, on the server and then was trying to make it dynamic with some jQuery and a lot of hacks. Backends were huge monolith applications, doing a ton of things, including analytics processing, which often ends up to be slow, inefficient, and hard to maintain. Thanks to microservices, containers, frontend frameworks, and a lot of great charting libraries it is easier and definitely more fun to build such analytics dashboards and report builders today.

In this React Dashboard tutorial, we’ll learn step by step how to build a full-stack analytics application, including a report builder and a dynamic dashboard. We’ll build our application in a microservice architecture with the frontend decoupled from the backend. We’ll rely on AWS services for some of the functionality, but that could be easily substituted by your own microservices, which we cover later in the tutorial.

You can check out the final application we are going to build here. The diagram below shows the architecture of our app.

Alt Text

Let’s go through the backend first -

We're going to store our data for the dashboard in PostgreSQL, a free and open-source relational database. For those who don’t have Postgres or would like to use a different database, I’ll put some useful links on how to do the same setup for different databases, such as MongoDB, later in this tutorial.

Next, we’ll install Cube and connect it to the database. Cube is an open-source analytical API platform for building analytical applications. It creates an analytics API on top of the database and handles things like SQL generation, caching, security, authentication, and much more.

We’ll also use AWS Cognito for user registrations and sign-ins and AWS AppSync as a GraphQL backend. Optionally, you can use your own authentication service, as well as GraphQL backend. But to keep things simple, we’ll rely on AWS services for the purpose of this tutorial.

The frontend is a React application. We’re going to use Cube Playground to generate a React dashboard boilerplate with a report builder and a dashboard. It uses Create React App under the hood to create all the configuration and additionally wires together all the components to work with Cube API and a GraphQL backend. Finally, for the visualizations, we’ll use Recharts, a powerful and customizable React-based charting library.

Update from April 2023. This guide was authored more than 3 years ago and certain parts (e.g., generation of the front-end boilerplate code) are not relevant anymore. Please see up-to-date front-end guides in the blog.

Setting up a Database and Cube

The first thing we need to have in place is a database. We’ll use a PostgreSQL database, but any other relational database should work fine as well. If you want to use MongoDB, you’d need to add a MongoDB Connector for BI. It allows you to execute SQL code on top of your MongoDB data. It can be easily downloaded from the MongoDB website.

One more thing to keep in mind is a replication. It is considered a bad practice to run analytics queries against your production database mostly because of the performance issues. Cube can dramatically reduce the amount of a database’s workload, but still, I’d recommend connecting to the replica.

If you don’t have any data for the dashboard, you can download our sample e-commerce Postgres dataset.

$ curl -L http://cube.dev/downloads/ecom-dump.sql > ecom-dump.sql
$ createdb ecom
$ psql --dbname ecom -f ecom-dump.sql

Now, let’s create an analytical API with Cube. Run the following command in your terminal:

$ npx cubejs-cli create react-dashboard -d postgres

We’ve just created a new Cube service, configured to work with the Postgres database. Cube uses environment variables starting with CUBEJS_ for configuration. To configure the connection to our database, we need to specify the DB type and name. In the Cube project folder replace the contents of .env with the following:

CUBEJS_DB_TYPE=postgres
CUBEJS_DB_NAME=ecom
CUBEJS_API_SECRET=SECRET
CUBEJS_DEV_MODE=true

If you are using a different database, please refer to this documentation on how to connect to a database of your choice.

Now, let’s run Cube Playground. It will help us to build a simple data schema, test out the charts, and then generate a React dashboard boilerplate. Run the following command in the Cube project folder:

$ npm run dev

Next, open http://localhost:4000/ in your browser to create a Cube data schema.

Cube uses the data schema to generate an SQL code, which will be executed in your database. The data schema is not a replacement for SQL. It is designed to make SQL reusable and give it a structure while preserving all of its power. Basic elements of the data schema are measures and dimensions.

Measure is referred to as quantitative data, such as the number of units sold, number of unique visits, profit, and so on.

Dimension is referred to as categorical data, such as state, gender, product name, or units of time (e.g., day, week, month).

Data schema is a JavaScript code, which defines measures and dimensions and how they map to SQL queries. Here is an example of the schema, which can be used to describe users’ data.

cube(`Users`, {
sql: `SELECT * FROM users`,
measures: {
count: {
sql: `id`,
type: `count`
}
},
dimensions: {
city: {
sql: `city`,
type: `string`
},
signedUp: {
sql: `created_at`,
type: `time`
},
companyName: {
sql: `company_name`,
type: `string`
}
}
});

Cube can generate a simple data schema based on the database’s tables. Let’s select the orders, line_items, products, and product_categories tables and click “Generate Schema.” It will generate 4 schema files, one per table.

Alt Text

Once the schema is generated, we can navigate to the “Build” tab and select some measures and dimensions to test out the schema. The "Build" tab is a place where you can build sample charts with different visualization libraries and inspect how that chart was created, starting from the generated SQL all the way up to the JavaScript code to render the chart. You can also inspect the JSON query, which is sent to Cube backend.

Alt Text

Generating a Dashboard Template

The next step is to generate a template of our frontend application. Navigate to “Dashboard App,” select React and Recharts, and click on the “Create dashboard app” button.

Alt Text

It could take a while to generate an app and install all the dependencies. Once it is done, you will have a dashboard-app folder inside your Cube project folder. To start a frontend application, either go to the “Dashboard App” tab in the playground and hit the “Start” button, or run the following command inside the dashboard-app folder:

$ npm start

Make sure the Cube backend process is up and running since our frontend application uses its API. The frontend application is running on http://localhost:3000/. If you open it in your browser, you should be able to see an Explore tab with a query builder and an empty Dashboard tab. Feel free to play around to create some charts and save them to the dashboard.

Our generated application uses the Apollo GraphQL client to store dashboard items into local storage. In the next part, we will add persistent storage with AWS AppSync, as well as user authentication with AWS Cognito.

Authentication and GraphQL API

Now we have a basic version of our app, which uses local storage to save charts on the dashboard. It is handy for development and prototyping, but is not suitable for real-world use cases. We want to let our users create dashboards and not lose them when they change the browser.

To do so, we first need to add authentication to our application and then save the users’ dashboard in the database. We are going to use AWS Cognito for authentication. AWS Cognito User Pool makes it easy for developers to add sign-up and sign-in functionality to web and mobile applications. It supports user registration and sign-in, as well as provisioning identity tokens for signed-in users.

To store the dashboards, we will use AWS AppSync. It allows us to create a flexible API to access and manipulate data and uses GraphQL as a query language. AppSync natively integrates with Cognito and can use its identity tokens to manage the ownership of the data—and in our case, the ownership of the dashboards. As a prerequisite to this part you need to have an AWS account, so you can use its services.

Besides AWS AppSync you can use any other GraphQL server to persist your dashboard data and athenticate/authorize your users. Cube itself doesn't have any dependencies on dashboard data persistance and it's completely up to your frontend application on how to handle this implementation.

Install and Configure Amplify CLI

I highly recommend using Yarn instead of NPM while working with our dashboard app. It is better at managing dependencies, and specifically in our case we'll use some of its features such as resolutions to make sure all the dependieces are installed correctly.

To switch to Yarn, delete node/_modules folder and package-lock.json inside dashboard-folder

$ cd dashboard-app && rm -rf node_modules && rm package-lock.json

To configure all these services, we will use AWS Amplify and its CLI tool. It uses AWS CloudFormation and enables us to easily add and modify backend configurations. First, let’s install the CLI itself.

$ yarn global add @aws-amplify/cli

Once installed, we need to setup the CLI with the appropriate permissions (a handy step by step video tutorial is also available here). Execute the following command to configure Amplify. It will prompt the creation of an IAM User in the AWS Console—once you create it, just copy and paste the credentials and select a profile name.

$ amplify configure

To initialize Amplify in our application, run the following command inside the dashboard-app folder.

$ cd project-folder/dashboard-app
$ amplify init

Create and Deploy AppSync GraphQL API

Next, let’s add Cognito and AppSync GraphQL API.

$ amplify add api
? Please select from one of the below mentioned services GraphQL
? Provide API name: yourAppName
? Choose the default authorization type for the API Amazon Cognito User Pool
Using service: Cognito, provided by: awscloudformation
The current configured provider is Amazon Cognito.
Do you want to use the default authentication and security configuration? Default configuration
Warning: you will not be able to edit these selections.
How do you want users to be able to sign in? Email
Do you want to configure advanced settings? No, I am done.
Successfully added auth resource
? Do you want to configure advanced settings for the GraphQL API? No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? Yes
? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? Yes

At this point, your default editor will be opened. Delete the provided sample GraphQL schema and replace it with:

type DashboardItem @model @auth(rules: [{allow: owner}]) {
id: ID!
name: String
layout: AWSJSON
vizState: AWSJSON
}

Back to the terminal, finish running the command and then execute:

$ amplify push
? Do you want to generate code for your newly created GraphQL API No

The command above will configure and deploy the Cognito Users Pool and the AppSync GraphQL API backend by DynamoDB table. It will also wire up everything together, so Cognito’s tokens can be used to control the ownership of the dashboard items.

After everything is deployed and set up, the identifiers for each resource are automatically added to a local aws_exports.js file that is used by AWS Amplify to reference the specific Auth and API cloud backend resources.

Cube API Authentication

We're going to use Cognito's identity tokens to manage access to Cube and the underlying analytics data. Cube comes with a flexible security model, designed to manage access to the data on different levels. The usual flow is to use JSON Web Tokens (JWT) for the authentication/authorization. The JWT tokens can carry a payload, such as a user ID, which can then be passed to the data schema as a security context to restrict access to some part of the data.

In our tutorial, we're not going to restrict users to access data, but we'll just authenticate them based on JWT tokens from Cognito. When a user signs in to our app, we'll request a JWT token for that user and then sign all the requests to the Cube backend with this token.

To verify the token on the Cube side, we need to download the public JSON Web Key Set (JWKS) for our Cognito User Pool. It is a JSON file and you can locate it at https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json.

You can find region and userPoolId in your src/aws_exports.js. Your file should look like the following, just copy the region and user pool id values.

// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.
const awsmobile = {
"aws_project_region": "XXX",
"aws_cognito_identity_pool_id": "XXX",
"aws_cognito_region": "REGION",
"aws_user_pools_id": "USER-POOL-ID",
"aws_user_pools_web_client_id": "XXX",
"oauth": {},
"aws_appsync_graphqlEndpoint": "XXX",
"aws_appsync_region": "XXX",
"aws_appsync_authenticationType": "XXX"
};
export default awsmobile;

Next, run the following command in the terminal to download JWKS into the root folder of your project. Make sure to replace region and userPoolId with the values from aws_exports.js.

$ cd react-dashboard
$ curl https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json > jwks.json

Now, we can use the JWKS to verify the JWT token from the client. Cube Server has the checkAuth option for this purpose. It is a function that accepts an auth token and expects you to provide a security context for the schema or throw an error in case the token is not valid.

Let's first install some packages we would need to work with JWT. Run the following command in your project root folder.

$ npm install -s jsonwebtoken jwk-to-pem lodash

Now, we need to update the cube.js file, which is just another way to configure Cube. Replace the content of the cube.js file with the following. Make sure to make these changes in the Cube root folder and not in the dashboard-app folder.

const fs = require("fs");
const jwt = require("jsonwebtoken");
const jwkToPem = require("jwk-to-pem");
const jwks = JSON.parse(fs.readFileSync("jwks.json"));
const _ = require("lodash");
module.exports = {
checkAuth: async (req, auth) => {
const decoded = jwt.decode(auth, { complete: true });
const jwk = _.find(jwks.keys, x => x.kid === decoded.header.kid);
const pem = jwkToPem(jwk);
req.securityContext = jwt.verify(auth, pem);
}
};

Here we first decode the incoming JWT token to find its kid. Then, based on the kid we pick a corresponding JWK and convert it into PEM. And, finally, verify the token. If either the decode or verification process fails, the error will be thrown.

That's all on the backend side. Now, let's add the authentication to our frontend app.

Add Authentication to the App

First, we need to install Amplify and AppSync-related dependencies to make our application work with a backend we just created. It is currently known that some versions conflict in the packages, so please make sure to install specific versions as listed below. To solve this issue, we'll use Yarn resolutions feature and specify a version of apollo-client we need to use. Open your package.json file and add the following property.

"resolutions": {
"apollo-client": "2.6.3"
}

Then, install the following packages.

$ yarn add apollo-client aws-amplify aws-amplify-react aws-appsync aws-appsync-react react-apollo@2.5.8

Now we need to update our App.js to add Cognito authentication and AppSync GraphQL API. First, we are wrapping our App with withAuthenticator HOC. It will handle sign-up and sign-in in our application. You can customize the set of the fields in the forms or completely rebuild the UI. Amplify documentation covers authentication configuration and customization.

Next, we are initiating the AWSAppSyncClient client to work with our AppSync backend. It is going to use credentials from Cognito to access data in AppSync and scope it on a per-user basis.

Update the content of the src/App.js file with the following.

import React from "react";
import { withRouter } from "react-router";
import { Layout } from "antd";
import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloProvider as ApolloHooksProvider } from "@apollo/react-hooks";
import { ApolloProvider } from "react-apollo";
import AWSAppSyncClient, { AUTH_TYPE } from "aws-appsync";
import { Rehydrated } from "aws-appsync-react";
import cubejs from "@cubejs-client/core";
import { CubeProvider } from "@cubejs-client/react";
import { withAuthenticator } from "aws-amplify-react";
import Amplify, { Auth, Hub } from 'aws-amplify';
import Header from './components/Header';
import aws_exports from './aws-exports';
const API_URL = "http://localhost:4000";
const cubejsApi = cubejs(
async () => (await Auth.currentSession()).getIdToken().getJwtToken(),
{ apiUrl: `${API_URL}/cubejs-api/v1` }
);
Amplify.configure(aws_exports);
const client = new AWSAppSyncClient(
{
disableOffline: true,
url: aws_exports.aws_appsync_graphqlEndpoint,
region: aws_exports.aws_appsync_region,
auth: {
type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
jwtToken: async () => (await Auth.currentSession()).getIdToken().getJwtToken()
},
},
{ cache: new InMemoryCache() }
);
Hub.listen('auth', (data) => {
if (data.payload.event === 'signOut') {
client.resetStore();
}
});
const AppLayout = ({ location, children }) => (
<Layout style={{ height: "100%" }}>
<Header location={location} />
<Layout.Content>{children}</Layout.Content>
</Layout>
);
const App = withRouter(({ location, children }) => (
<CubeProvider cubejsApi={cubejsApi}>
<ApolloProvider client={client}>
<ApolloHooksProvider client={client}>
<Rehydrated>
<AppLayout location={location}>{children}</AppLayout>
</Rehydrated>
</ApolloHooksProvider>
</ApolloProvider>
</CubeProvider>
));
export default withAuthenticator(App, {
signUpConfig: {
hiddenDefaults: ["phone_number"]
}
});

Update GraphQL Queries and Mutations

The next step is to update our GraphQL queries and mutations to work with the just-created AppSync backend.

Replace the content of the src/graphql/mutations.js file with following.

import gql from "graphql-tag";
export const CREATE_DASHBOARD_ITEM = gql`
mutation CreateDashboardItem($input: CreateDashboardItemInput!) {
createDashboardItem(input: $input) {
id
layout
vizState
name
}
}
`;
export const UPDATE_DASHBOARD_ITEM = gql`
mutation UpdateDashboardItem($input: UpdateDashboardItemInput!) {
updateDashboardItem(input: $input) {
id
layout
vizState
name
}
}
`;
export const DELETE_DASHBOARD_ITEM = gql`
mutation DeleteDashboardItem($id: ID!) {
deleteDashboardItem(input: { id: $id }) {
id
layout
vizState
name
}
}
`;

And then replace src/graphql/queries.js with the following.

import gql from "graphql-tag";
export const GET_DASHBOARD_ITEMS = gql`query ListDashboardItems {
listDashboardItems {
items {
id
layout
vizState
name
}
}
}
`
export const GET_DASHBOARD_ITEM = gql`query GetDashboardItem($id: ID!) {
dashboardItem: getDashboardItem(id: $id) {
id
layout
vizState
name
}
}
`;

Our new updated queries are a little bit different from the original ones. We need to make some small updates to our components’ code to make it work new queries and mutations.

First, in the src/components/Dashboard.js and src/components/TitleModal.js files, change how the variables are passed to the updateDashboardItem function.

// on the line 30 in src/components/Dashboard.js
// update the variables passed to `updateDashboardItem` function
updateDashboardItem({
variables: {
input: {
id: item.id,
layout: toUpdate
}
}
});
// Similarly update variables on the line 44 in src/components/TitleModal.js
await (itemId ? updateDashboardItem : addDashboardItem)({
variables: {
input: {
id: itemId,
vizState: JSON.stringify(finalVizState),
name: finalTitle
}
}
});

Lastly, update how data is accessed in src/pages/DashboardPage.js.

// on the line 66 and the following change data.dashboardItems to
// data.listDashboardItems.items
return !data || data.listDashboardItems.items.length ? (
<Dashboard dashboardItems={data && data.listDashboardItems.items}>
{data && data.listDashboardItems.items.map(deserializeItem).map(dashboardItem)}
</Dashboard>
) : <Empty />;

Those are all the changes required to make our application work with AWS Cognito and AppSync. Now we have a fully functional application with authorization and a GraphQL backend.

Go ahead and restart your Cube backend and dashboard app servers and then navigate to http://localhost:3000/ to test it locally. You should see Cognito’s default sign-up and sign-in pages. Once registered, you can create your own dashboard, which is going to be stored in the cloud by AppSync.

In the next chapter, we will start customizing our application by editing default theme and updating the design of the top menu.

Customize Theme

The dashboard template we generated is using Ant Design UI React library for all the UI components. It is one of the most popular React UI kits alongside Material UI. It uses Less as a stylesheet language and allows us to customize the design by overriding default Less variables.

As I mentioned in the first chapter, our Dashboard App is based on Create React App (CRA). Currently, it doesn’t support Less out of the box, and to make it work, we need to use an eject command.

Create React App provides a fully configured environment and a default configuration. And all this configuration is hidden from you. But when you eject, all that configuration will be exposed to you. It means that you will get full control and will be able to add things like Less support. But at the same time, you will be responsible for maintaining all of that configuration.

eject is irreversible. You need to commit your changes before and then run eject in the dashboard-app folder.

$ git add -A
$ git commit -m "Initial"
$ yarn eject

Once you’ve run it, you can find a new folder called config. Inside the config folder, you can find all the project configuration files, but today we only need the webpack.config.js file.

Now let’s install Less.

$ yarn add less less-loader

Next, we need to modify the webpack configuration file. Open config/webpack.config.js and Find the cssRegex constant and change it:

-const cssRegex = /\.css$/;
+const cssRegex = /\.(?:le|c)ss$/;

Then, find the getStyleLoaders function. On the loaders array, after the css-loader, add the LESS loader. Make sure your code looks like this:

// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
// ...
{
loader: require.resolve('css-loader'),
options: cssOptions,
},
{
loader: require.resolve('less-loader'),
options: {
lessOptions: {
javascriptEnabled: true,
},
}
},
// ...

That’s it! With this in place, we are ready to override some of the antd’s default variables and styles. We are going to customize some variables according to the antd’s Customize Theme guide. Create a dashboard-app/src/variables.less file with the following content.

// Colors
@dark-blue: #43436B;
@primary-color: @blue-6;
// Base Scaffolding Variables
@font-family: 'DM Sans', sans-serif;
@font-size-base: 16px;
@body-background: #EEEEF5;
@heading-color: @dark-blue;
@text-color: #878F9F;
// Layout
@layout-header-background: @dark-blue;
@layout-body-background: #EEEEF5;
@layout-header-height: 48px;
// Buttons
@btn-primary-bg: #FF6492;
@btn-height-base: 40px;
@btn-disable-color: white;
@btn-disable-bg: #FF6492;
@btn-disable-border: #FF6492;
@btn-default-color: @dark-blue;
@btn-default-border: #D0D0DA;
// Input
@input-color: @dark-blue;
@input-height-base: 40px;
// Select
@select-border-color: #ECECF0;
// Modal
@modal-body-padding: 32px;
// Typography
@typography-title-font-weight: bold;

Next, let’s create a index.less file, which will be imported in index.js. Here, we do several things: import antd styles, import the Dm Sans font from Google Fonts, import the just-created file with modified variables, and finally, add some minor customization.

@import '~antd/dist/antd.less';
@import url('https://fonts.googleapis.com/css?family=DM+Sans&display=swap&css');
@import 'variables.less';
.ant-btn-primary[disabled] {
opacity: 0.4;
}
.ant-modal-header {
border-bottom: none;
padding: 40px 32px 0 32px;
}
.ant-modal-footer {
border-top: none;
padding: 0 32px 40px 32px;
text-align: left;
}
.ant-select {
color: @dark-blue;
}
.ant-select-dropdown-menu-item {
color: @dark-blue;
}

The last thing is to import index.less in our index.js. Add this import to the file

// ...
import App from './App';
+ import "./index.less";
// ...

The styles above customize our design globally, changing the look of some components. But to customize some specific components, like the top menu, we are going to use Styled Components.

Styled Components allows you to write CSS right inside your components. It is a variant of “CSS-in-JS”—which solves many of the problems with traditional CSS like selector name collisions.

Let’s first add styled-components to our project.

$ yarn add styled-components

The first component to style with Styled Components is going to be the <Header />. Let’s first download a logo in SVG. We are using the Cube logo here as an example, but you can place your product’s logo the same way.

$ cd dashboard-app/src/components && curl https://cube.dev/downloads/logo.svg > logo.svg

Next, replace the content of the src/components/Header.js with the following.

import React from "react";
import { SignOut } from "aws-amplify-react";
import { Layout, Menu } from "antd";
import { Link } from "react-router-dom";
import styled from 'styled-components';
import logo from './logo.svg';
const StyledHeader = styled(Layout.Header)`
padding: 0 28px
`
const StyledMenu = styled(Menu)`
background: transparent;
line-height: 41px;
`
const MenuItemStyled = styled(Menu.Item)`
&& {
top: 4px;
border-bottom: 4px solid transparent;
&:hover {
border-bottom: 4px solid transparent;
& > a {
color: #ffffff;
opacity: 1;
}
}
}
&&.ant-menu-item-selected
{
color: white;
border-bottom: 4px solid white;
& > a {
opacity: 1;
}
}
&& > a {
color: #ffffff;
opacity: 0.60;
font-weight: bold;
letter-spacing: 0.01em;
}
`
const Logo = styled.div`
float: left;
margin-right: 40px;
`
const signOutStyles = {
navButton: {
color: "white",
background: "none",
textTransform: "none",
fontSize: "13px",
fontWeight: "bold",
minWidth: 0
}
}
const Header = ({ location }) => (
<StyledHeader >
<Logo>
<img src={logo} />
</Logo>
<StyledMenu
mode="horizontal"
selectedKeys={[location.pathname]}
>
<MenuItemStyled key="/explore">
<Link to="/explore">Explore</Link>
</MenuItemStyled>
<MenuItemStyled key="/">
<Link to="/">Dashboard</Link>
</MenuItemStyled>
<MenuItemStyled style={{ float: "right", paddingRight: 0 }} key="sign-out">
<SignOut theme={signOutStyles} />
</MenuItemStyled>
</StyledMenu>
</StyledHeader>
);
export default withRouter(Header);

Yay! We’ve finished another chapter. We have customized global antd variables and updated the design of our navigation bar. Restart the Dashboard App server and test the changes at http://localhost:3000/.

In the next chapter, we are going to customize the layout of the Explore page.

Explore Page

On the Explore page, the user either creates a new chart or updates the existing one. To do so, the user should be able to select measures, dimensions, apply filters if needed, and select a chart type. We also need to provide a way to save a new chart to the dashboard or to update the existing one when editing.

The image below shows how the page will look after the styling is applied.

Alt Text

Let’s break down the page into components. We need a modal window to let the user set or update the chart’s title—<TitleModal />. Next, we have a page’s header—<PageHeader />. Inside the page’s header to the right, we have a button that is used to either add a chart to the dashboard or update it. It is a regular <Button /> component from antd’s UI kit. To the left—a page’s title, which either displays a chart’s title when editing or just says ‘Explore’ when we are building a new one. Let’s call it an <ExploreTitle /> component.

Finally, we have a complex component to render a query builder and a chart itself. The dashboard app already generated this component for us—<ExploreQueryBuilder />. We’ll take a closer look at this component, its structure, and how we will customize it in the next section. Now let’s create the above components and add them to the Explore page.

The <TitleModal /> component is already generated by our dashboard app. And since we’ve updated some style variables in the previous chapter, it already fits into our design, so we don’t need to update it anymore.

We don’t have a <PageHeader /> component yet, so let’s create one. This component is going to be reused on the Dashboard page and should act as a container for our title and the button.

Create a src/components/PageHeader.js file the following content.

import React from "react";
import { Row, Col } from "antd";
import styled from 'styled-components';
const StyledRow = styled(Row)`
padding: 23px 28px 13px 28px;
background: white;
`
const ButtonsCol = styled(Col)`
text-align: right;
`
const PageHeader = ({ title, button, noBorder }) => (
<StyledRow>
<Col span={12}>
{title}
</Col>
<ButtonsCol span={12}>
{button}
</ButtonsCol>
</StyledRow>
);
export default PageHeader;

Next, let’s create the <ExploreTitle /> component. It will display the chart’s title if the itemTitle variable is not null, otherwise it will just display “Explore” as a page title.

Create the src/components/ExploreTitle.js file the following content.

import React from "react";
import { Typography } from "antd";
import { Link } from "react-router-dom";
import styled from 'styled-components';
const StyledLink = styled(Link)`
&& {
color: #D5D5DE;
&:hover {
color: #7A77FF;
}
}
`
const StyledDash = styled.span`
color: #D5D5DE;
`
const ExploreTitle = ({ itemTitle }) => (
<Typography.Title level={4}>
{ itemTitle ?
(
<span>
<StyledLink to="/">Dashboard</StyledLink>
<StyledDash></StyledDash>
{itemTitle}
</span>
) : "Explore" }
</Typography.Title>
);
export default ExploreTitle;

Finally, we need to update the Explore page with new components and a new layout. First, import the newly created components and the isQueryPresent helper method from @cubejs-client/react. We’re going to use the isQueryPresent method to disable the “Add to Dashboard” button when a query is not present.

Add the following lines to the imports block in src/pages/ExplorePage.js.

import { isQueryPresent } from "@cubejs-client/react";
import PageHeader from "../components/PageHeader.js";
import ExploreTitle from "../components/ExploreTitle.js";

Next, update the layout of the page. Update the entire return block in src/pages/ExplorePage.js with the following content.

return (
<div>
<TitleModal
history={history}
itemId={itemId}
titleModalVisible={titleModalVisible}
setTitleModalVisible={setTitleModalVisible}
setAddingToDashboard={setAddingToDashboard}
finalVizState={finalVizState}
setTitle={setTitle}
finalTitle={finalTitle}
/>
<PageHeader
title={<ExploreTitle itemId={itemId} />}
button={
<Button
key="button"
type="primary"
loading={addingToDashboard}
disabled={!isQueryPresent(finalVizState.query || {})}
onClick={() => setTitleModalVisible(true)}
>
{itemId ? "Update" : "Add to Dashboard"}
</Button>
}
/>
<ExploreQueryBuilder
vizState={finalVizState}
setVizState={setVizState}
/>
</div>
);

That gives us a layout we need for an Explore page. In the next two chapters we’ll customize the query builder and the chart itself.

Query Builder

In this part, we're going to make a lot of changes to style our query builder component. Feel free to skip this part if you don't need to style it.

The query builder component in the template, <ExploreQueryBuilder />, is built based on the <QueryBuilder /> component from the @cubejs-client/react package. The <QueryBuilder /> abstracts state management and API calls to Cube Backend. It uses render prop and doesn’t render anything itself, but calls the render function instead. This way it gives maximum flexibility to building a custom-tailored UI with a minimal API.

In our dashboard template, we have a lot of small components that render various query builder controls, such as measures/dimensions selects, filters, chart types select, etc. We'll go over each of them to apply new styles.

<ExploreQueryBuilder /> is a parent component, which first renders all the controls we need to build our query: measures, dimensions, segments, time, filters, and chart type selector controls. Then it renders the chart itself. It also provides a basic layout of all the controls.

Let's start with customizing this component first and then we'll update all the smaller components one by one. Replace the content of src/components/QueryBuilder/ExploreQueryBuilder.js with the following.

import React from "react";
import { Row, Col, Card, Divider } from "antd";
import styled from 'styled-components';
import { QueryBuilder } from "@cubejs-client/react";
import MemberGroup from "./MemberGroup";
import FilterGroup from "./FilterGroup";
import TimeGroup from "./TimeGroup";
import ChartRenderer from "../ChartRenderer";
import SelectChartType from './SelectChartType';
const ControlsRow = styled(Row)`
background: #ffffff;
margin-bottom: 12px;
padding: 18px 28px 10px 28px;
`
const StyledDivider = styled(Divider)`
margin: 0 12px;
height: 4.5em;
top: 0.5em;
background: #F4F5F6;
`
const HorizontalDivider = styled(Divider)`
padding: 0;
margin: 0;
background: #F4F5F6;
`
const ChartCard = styled(Card)`
border-radius: 4px;
border: none;
`
const ChartRow = styled(Row)`
padding-left: 28px;
padding-right: 28px;
`
const ExploreQueryBuilder = ({
vizState,
cubejsApi,
setVizState,
chartExtra
}) => (
<QueryBuilder
vizState={vizState}
setVizState={setVizState}
cubejsApi={cubejsApi}
wrapWithQueryRenderer={false}
render={({
measures,
availableMeasures,
updateMeasures,
dimensions,
availableDimensions,
updateDimensions,
segments,
availableSegments,
updateSegments,
filters,
updateFilters,
timeDimensions,
availableTimeDimensions,
updateTimeDimensions,
isQueryPresent,
chartType,
updateChartType,
validatedQuery,
cubejsApi
}) => [
<ControlsRow type="flex" justify="space-around" align="top" key="1">
<Col span={24}>
<Row type="flex" align="top" style={{ paddingBottom: 23}}>
<MemberGroup
title="Measures"
members={measures}
availableMembers={availableMeasures}
addMemberName="Measure"
updateMethods={updateMeasures}
/>
<StyledDivider type="vertical" />
<MemberGroup
title="Dimensions"
members={dimensions}
availableMembers={availableDimensions}
addMemberName="Dimension"
updateMethods={updateDimensions}
/>
<StyledDivider type="vertical"/>
<MemberGroup
title="Segments"
members={segments}
availableMembers={availableSegments}
addMemberName="Segment"
updateMethods={updateSegments}
/>
<StyledDivider type="vertical"/>
<TimeGroup
title="Time"
members={timeDimensions}
availableMembers={availableTimeDimensions}
addMemberName="Time"
updateMethods={updateTimeDimensions}
/>
</Row>
{!!isQueryPresent && ([
<HorizontalDivider />,
<Row type="flex" justify="space-around" align="top" gutter={24} style={{ marginTop: 10 }}>
<Col span={24}>
<FilterGroup
members={filters}
availableMembers={availableDimensions.concat(availableMeasures)}
addMemberName="Filter"
updateMethods={updateFilters}
/>
</Col>
</Row>
])}
</Col>
</ControlsRow>,
<ChartRow type="flex" justify="space-around" align="top" gutter={24} key="2">
<Col span={24}>
{isQueryPresent ? ([
<Row style={{ marginTop: 15, marginBottom: 25 }}>
<SelectChartType
chartType={chartType}
updateChartType={updateChartType}
/>
</Row>,
<ChartCard style={{ minHeight: 420 }}>
<ChartRenderer
vizState={{ query: validatedQuery, chartType }}
cubejsApi={cubejsApi}
/>
</ChartCard>
]) : (
<h2
style={{
textAlign: "center"
}}
>
Choose a measure or dimension to get started
</h2>
)}
</Col>
</ChartRow>
]}
/>
);
export default ExploreQueryBuilder;

There is a lot of code, but it's all about styling and rendering our layout with components' controls. Here we can see the following components, which render controls: <MemberGroup />, <TimeGroup />, <FilterGroup />, and <SelectChartType />. There is also a <ChartRenderer />, which we will update in the next part.

Now, let's customize each of the components we render here. The first one is a <MemberGroup />. It is used to render measures, dimensions, and segments.

Replace the content of the src/components/QueryBuilder/MemberGroup.js file with the following.

import React from 'react';
import MemberDropdown from './MemberDropdown';
import RemoveButtonGroup from './RemoveButtonGroup';
import MemberGroupTitle from './MemberGroupTitle';
import PlusIcon from './PlusIcon';
const MemberGroup = ({
members, availableMembers, addMemberName, updateMethods, title
}) => (
<div>
<MemberGroupTitle title={title} />
{members.map(m => (
<RemoveButtonGroup key={m.index || m.name} onRemoveClick={() => updateMethods.remove(m)}>
<MemberDropdown type="selected" availableMembers={availableMembers} onClick={updateWith => updateMethods.update(m, updateWith)}>
{m.title}
</MemberDropdown>
</RemoveButtonGroup>
))}
<MemberDropdown
type={members.length > 0 ? "icon" : "new"}
onClick={m => updateMethods.add(m)} availableMembers={availableMembers}
>
{addMemberName}
<PlusIcon />
</MemberDropdown>
</div>
);
export default MemberGroup;

Here we can see that <MemberGroup /> internally uses 4 main components to render the control: <MemberDropdown />, <RemoveButtonGroup />, <MemberGroupTitle />, and <PlusIcon />. Let's go over each of them.

We already have a <MemberDropdown /> component in place. If you inspect it, you will see that it uses <ButtonDropdown /> internally to render the button for the control. We are not going to customize <MemberDropdown />, but will do customization on the button instead.

Replace the content of src/components/QueryBuilder/ButtonDropdown.js with the following.

import React from 'react';
import { Button, Dropdown } from 'antd';
import PlusIcon from './PlusIcon';
import styled from 'styled-components';
const StyledButton = styled(Button)`
font-size: 14px;
height: 48px;
line-height: 3.5;
box-shadow: 0px 2px 12px rgba(67, 67, 107, 0.1);
border: none;
color: #43436B;
//animation-duration: 0s;
&:hover + a {
display: block;
}
&:hover, &.ant-dropdown-open, &:focus {
color: #43436B;
}
&:after {
animation: 0s;
}
& > i {
position: relative;
top: 3px;
}
`
const SelectedFilterButton = styled(StyledButton)`
&& {
height: 40px;
line-height: 40px;
box-shadow: none;
border: 1px solid #ECECF0;
border-radius: 4px;
}
`
const NewButton = styled(StyledButton)`
color: #7471f2;
border: 1px solid rgba(122, 119, 255, 0.2);
box-shadow: none;
font-weight: bold;
&:hover, &.ant-dropdown-open, &:focus {
color: #6D5AE5;
border-color: rgba(122, 119, 255, 0.2);
}
`
const TimeGroupButton = styled(NewButton)`
border: none;
padding: 0;
`
const PlusIconButton = styled.span`
margin-left: 12px;
top: 5px;
position: relative;
`
const ButtonDropdown = ({ overlay, type, ...buttonProps }) => {
let component;
if (type === 'icon') {
component = <PlusIconButton><PlusIcon /></PlusIconButton>;
} else if (type === 'selected') {
component = <StyledButton {...buttonProps} />;
} else if (type === 'time-group') {
component = <TimeGroupButton {...buttonProps} />;
} else if (type === 'selected-filter') {
component = <SelectedFilterButton {...buttonProps} />;
} else {
component = <NewButton {...buttonProps} />;
}
return (
<Dropdown overlay={overlay} placement="bottomLeft" trigger={['click']}>
{ component }
</Dropdown>
)
}
export default ButtonDropdown;

There are a lot of changes, but as you can see, mostly around the styles of the button. Depending on different states of the control, such as whether we already have selected a member or not, we are changing the style of the button.

Now, let's go back to our list of components to style; the next one is <RemoveButtonGroup />. It is quite a simple component that renders a button to remove selected measures or dimensions. As mentioned at the beginning of this part, the <QueryBuilder /> component from the @cubejs-client/react package takes care of all the logic and state, and we just need to render controls to perform actions.

Replace the content of src/components/QueryBuilder/RemoveButtonGroup.js with the following.

import React from 'react';
import { Button } from 'antd';
import removeButtonSvg from './remove-button.svg';
import styled from 'styled-components';
const StyledButton = styled.a`
height: 16px;
width: 16px;
background-image: url(${removeButtonSvg});
display: block;
position: absolute;
right: -5px;
top: -5px;
z-index: 9;
display: none;
&:hover {
background-position: 16px 0;
display: block;
}
`
const RemoveButtonGroup = ({ onRemoveClick, children, display, ...props }) => (
<Button.Group style={{ marginRight: 8 }} {...props}>
{children}
<StyledButton onClick={onRemoveClick} />
</Button.Group>
);
export default RemoveButtonGroup;

Here we use an SVG image for our button. If you follow our design, you can download it with the following command.

$ cd dashboard-app/src/components/QueryBuilder && curl https://cube.dev/downloads/remove-button.svg > remove-button.svg

The next component, <MemberGroupTitle />, doesn't add any functionality to our query builder, but just acts as a label for our controls.

Let's create src/components/QueryBuilder/MemberGroupTitle.js with the following content.

import React from 'react';
import styled from 'styled-components';
const LabelStyled = styled.div`
margin-bottom: 12px;
color: #A1A1B5;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 11px;
font-weight: bold;
`
const MemberGroupTitle = ({ title }) => (
<LabelStyled>{title}</LabelStyled>
);
export default MemberGroupTitle;

The last component from <MemberGroup /> is <PlusIcon />. It just renders the plus icon, which is used in all our controls.

Create src/components/QueryBuilder/PlusIcon.js with the following content.

import React from 'react';
import { Icon } from '@ant-design/icons';
import { ReactComponent as PlusIconSvg } from './plus-icon.svg';
import styled from 'styled-components';
const PlusIconStyled = styled(Icon)`
display: inline-block;
background: #6F76D9;
border-radius: 50%;
width: 20px;
height: 20px;
position: relative;
cursor: pointer;
pointer-events: all !important;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(122,119,255,0.1);
border-radius: 50%;
transition: transform 0.15s cubic-bezier(0.0, 0.0, 0.2, 1);
z-index: 1;
}
&:hover::after {
transform: scale(1.4);
}
& svg {
width: 20px;
height: 20px;
z-index: 2;
}
`
const PlusIcon = () => (
<PlusIconStyled component={PlusIconSvg} />
);
export default PlusIcon;

Same as for the remove button icon, you can download an SVG of the plus icon with the following command.

$ cd dashboard-app/src/components/QueryBuilder && curl https://cube.dev/downloads/plus-icon.svg > plus-icon.svg

Now, we have all the components for the <MemberGroup /> and we are almost done with the controls styling. Next, let's update the <TimeGroup />, <FilterGroup />, and <SelectChartType /> components.

Replace the contents of src/components/QueryBuilder/TimeGroup.js with the following.

import React from 'react';
import {
Menu
} from 'antd';
import ButtonDropdown from './ButtonDropdown';
import MemberDropdown from './MemberDropdown';
import RemoveButtonGroup from './RemoveButtonGroup';
import MemberGroupTitle from './MemberGroupTitle';
import PlusIcon from './PlusIcon';
import styled from 'styled-components';
const DateRanges = [
{ title: 'All time', value: undefined },
{ value: 'Today' },
{ value: 'Yesterday' },
{ value: 'This week' },
{ value: 'This month' },
{ value: 'This quarter' },
{ value: 'This year' },
{ value: 'Last 7 days' },
{ value: 'Last 30 days' },
{ value: 'Last week' },
{ value: 'Last month' },
{ value: 'Last quarter' },
{ value: 'Last year' }
];
const GroupLabel = styled.span`
font-size: 14px;
margin: 0 12px;
`
const TimeGroup = ({
members, availableMembers, addMemberName, updateMethods, title
}) => {
const granularityMenu = (member, onClick) => (
<Menu>
{member.granularities.length ? member.granularities.map(m => (
<Menu.Item key={m.title} onClick={() => onClick(m)}>
{m.title}
</Menu.Item>
)) : <Menu.Item disabled>No members found</Menu.Item>}
</Menu>
);
const dateRangeMenu = (onClick) => (
<Menu>
{DateRanges.map(m => (
<Menu.Item key={m.title || m.value} onClick={() => onClick(m)}>
{m.title || m.value}
</Menu.Item>
))}
</Menu>
);
return (
<div>
<MemberGroupTitle title={title} />
{members.map(m => [
<RemoveButtonGroup onRemoveClick={() => updateMethods.remove(m)} key={`${m.dimension.name}-member`}>
<MemberDropdown
type="selected"
onClick={updateWith => updateMethods.update(m, { ...m, dimension: updateWith })}
availableMembers={availableMembers}
>
{m.dimension.title}
</MemberDropdown>
</RemoveButtonGroup>,
<GroupLabel key={`${m.dimension.name}-for`}>for</GroupLabel>,
<ButtonDropdown
type="time-group"
overlay={dateRangeMenu(dateRange => updateMethods.update(m, { ...m, dateRange: dateRange.value }))}
key={`${m.dimension.name}-date-range`}
>
{m.dateRange || 'All time'}
</ButtonDropdown>,
<GroupLabel key={`${m.dimension.name}-by`}>by</GroupLabel>,
<ButtonDropdown
type="time-group"
overlay={granularityMenu(
m.dimension,
granularity => updateMethods.update(m, { ...m, granularity: granularity.name })
)}
key={`${m.dimension.name}-granularity`}
>
{
m.dimension.granularities.find(g => g.name === m.granularity)
&& m.dimension.granularities.find(g => g.name === m.granularity).title
}
</ButtonDropdown>
])}
{!members.length && (
<MemberDropdown
onClick={member => updateMethods.add({ dimension: member, granularity: 'day' })}
availableMembers={availableMembers}
type="new"
>
{addMemberName}
<PlusIcon />
</MemberDropdown>
)}
</div>
);
};
export default TimeGroup;

Finally, update the <FilterGroup /> component in src/components/QueryBuilder/FilterGroup.js.

import React from 'react';
import { Select } from 'antd';
import MemberDropdown from './MemberDropdown';
import RemoveButtonGroup from './RemoveButtonGroup';
import FilterInput from './FilterInput';
import PlusIcon from './PlusIcon';
const FilterGroup = ({
members, availableMembers, addMemberName, updateMethods
}) => (
<span>
{members.map(m => (
<div style={{ marginBottom: 12 }} key={m.index}>
<RemoveButtonGroup onRemoveClick={() => updateMethods.remove(m)}>
<MemberDropdown
type="selected-filter"
onClick={updateWith => updateMethods.update(m, { ...m, dimension: updateWith })}
availableMembers={availableMembers}
style={{
width: 150,
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
{m.dimension.title}
</MemberDropdown>
</RemoveButtonGroup>
<Select
value={m.operator}
onChange={(operator) => updateMethods.update(m, { ...m, operator })}
style={{ width: 200, marginRight: 8 }}
>
{m.operators.map(operator => (
<Select.Option
key={operator.name}
value={operator.name}
>
{operator.title}
</Select.Option>
))}
</Select>
<FilterInput member={m} key="filterInput" updateMethods={updateMethods}/>
</div>
))}
<MemberDropdown
onClick={(m) => updateMethods.add({ dimension: m })}
availableMembers={availableMembers}
type="new"
>
{addMemberName}
<PlusIcon />
</MemberDropdown>
</span>
);
export default FilterGroup;

The last component we're going to update is <SelectChartType />. Replace the content of src/components/QueryBuilder/SelectChartType.js with the following.

import React from 'react';
import {
Menu, Icon, Dropdown
} from 'antd';
import styled from 'styled-components';
const StyledDropdownTrigger = styled.span`
color: #43436B;
cursor: pointer;
margin-left: 13px;
& > span {
margin: 0 8px;
}
`
const ChartTypes = [
{ name: 'line', title: 'Line', icon: 'line-chart' },
{ name: 'area', title: 'Area', icon: 'area-chart' },
{ name: 'bar', title: 'Bar', icon: 'bar-chart' },
{ name: 'pie', title: 'Pie', icon: 'pie-chart' },
{ name: 'table', title: 'Table', icon: 'table' },
{ name: 'number', title: 'Number', icon: 'info-circle' }
];
const SelectChartType = ({ chartType, updateChartType }) => {
const menu = (
<Menu>
{ChartTypes.map(m => (
<Menu.Item key={m.title} onClick={() => updateChartType(m.name)}>
<Icon type={m.icon} />
&nbsp;{m.title}
</Menu.Item>
))}
</Menu>
);
const foundChartType = ChartTypes.find(t => t.name === chartType);
return (
<Dropdown overlay={menu} icon={foundChartType.icon} lacement="bottomLeft" trigger={['click']}>
<StyledDropdownTrigger>
<Icon type={foundChartType.icon} />
<span>{foundChartType.title}</span>
<Icon type="caret-down" />
</StyledDropdownTrigger>
</Dropdown>
);
};
export default SelectChartType;

That's it! Those were a lot of changes, but now we have a fully custom query builder. I hope it gives you an idea of how you can customize such a component to fit your design.

Next, we are going to style the charts.

Charts Styling

When we created a dashboard app in the first chapter, we selected Recharts as our visualization library. Recharts provides a set of charting components, which you can mix together to build different kinds of charts. It is also quite powerful when it comes to customization.

Every component in the Recharts library accepts multiple properties that control its look and feel. You can learn more about the Recharts components API here. We are going to use these properties and a little CSS to customize charts according to our design.

The first step is to provide correct and nice formatting for numbers and dates. We’re going to accomplish that with the help of two libraries: moment—for date formatting, and numeral—for number formatting. Let’s install them.

$ yarn add numeral moment

Next, we’re adding some CSS to customize the SVG elements of the charts. Create a dashboard-app/src/components/recharts-theme.less file with the following content.

.recharts-cartesian-grid-horizontal {
line {
stroke-dasharray: 2, 2;
stroke: #D0D0DA;
}
}
.recharts-cartesian-axis-tick-value {
tspan {
fill: #A1A1B5;
letter-spacing: 0.03em;
//font-weight: bold;
font-size: 14px;
}
}

Finally, let’s import our CSS, define formatters, and pass customization properties to the charts’ components. Make the following changes in the src/components/ChartRenderer.js file.

import "./recharts-theme.less";
import moment from "moment";
import numeral from "numeral";
const numberFormatter = item => numeral(item).format("0,0");
const dateFormatter = item => moment(item).format("MMM YY");
const colors = ["#7DB3FF", "#49457B", "#FF7C78"];
const xAxisFormatter = (item) => {
if (moment(item).isValid()) {
return dateFormatter(item)
} else {
return item;
}
}
const CartesianChart = ({ resultSet, children, ChartComponent }) => (
<ResponsiveContainer width="100%" height={350}>
<ChartComponent margin={{ left: -10 }} data={resultSet.chartPivot()}>
<XAxis axisLine={false} tickLine={false} tickFormatter={xAxisFormatter} dataKey="x" minTickGap={20} />
<YAxis axisLine={false} tickLine={false} tickFormatter={numberFormatter} />
<CartesianGrid vertical={false} />
{ children }
<Legend />
<Tooltip labelFormatter={dateFormatter} formatter={numberFormatter} />
</ChartComponent>
</ResponsiveContainer>
)

That is all on charts customization. Depending on the library and how much you want to customize your charts’ look and feel, you can end with less or more changes, but for our design, we are good with the above changes. Head out to http://localhost:3000/ to check out your new charts’ styles.

Finally, we're done with customization and are ready to deploy our dashboard. That is what we are going to cover in our next part.

Dashboard Page

This is going to be a small section. We have already created some components, while customizing the Explore page, which we are going to reuse here. The image below shows the final look of the Dashboard page after we finish styling it.

First, we are going to add the <PageHeader /> component to the Dashboard page. We’ve already created it for the Explore page, so let’s reuse it here.

Make the following changes to the src/pages/DashboardPage.js file.

import React from "react";
-import { Spin, Button, Alert } from "antd";
+import { Spin, Button, Alert, Typography } from "antd";
import { Link } from "react-router-dom";
import { useQuery } from "@apollo/react-hooks";
import { GET_DASHBOARD_ITEMS } from "../graphql/queries";
import ChartRenderer from "../components/ChartRenderer";
import Dashboard from "../components/Dashboard";
import DashboardItem from "../components/DashboardItem";
+import PageHeader from "../components/PageHeader";
return !data || data.listDashboardItems.items.length ? (
- <Dashboard dashboardItems={data && data.listDashboardItems.items}>
- {data && data.listDashboardItems.items.map(deserializeItem).map(dashboardItem)}
- </Dashboard>
+ <div>
+ <PageHeader
+ title={<Typography.Title level={4}>Dashboard</Typography.Title>}
+ button={<Link to="/explore">
+ <Button type="primary">
+ Add chart
+ </Button>
+ </Link>}
+ />
+ <Dashboard dashboardItems={data && data.listDashboardItems.items}>
+ {data && data.listDashboardItems.items.map(deserializeItem).map(dashboardItem)}
+ </Dashboard>
+ </div>
) : <Empty />;

Now, we need to make some small changes to the layout of the dashboard itself in the <Dashboard /> component and the look of the dashboard item in the <DashboardItem /> component.

Make the following changes in src/components/Dashboard.js.

-<ReactGridLayout cols={12} rowHeight={50} onLayoutChange={onLayoutChange}>
+<ReactGridLayout
+ style={{marginLeft: 18, marginRight: 18, marginTop: 6}}
+ cols={12}
+ rowHeight={50}
+ onLayoutChange={onLayoutChange}
+ >

Let’s update the styles of the <DashboardItem /> with Styled Components and also change the icon for the dropdown menu. Update src/components/DashboardItem.js as shown below.

import React from "react";
-import { Card, Menu, Button, Dropdown, Modal } from "antd";
+import { Card, Menu, Dropdown, Modal } from "antd";
+import { Icon } from "@ant-design/icons";
+import styled from 'styled-components';
import { useMutation } from "@apollo/react-hooks";
import { Link } from "react-router-dom";
import { GET_DASHBOARD_ITEMS } from "../graphql/queries";
import { DELETE_DASHBOARD_ITEM } from "../graphql/mutations";
+const StyledCard = styled(Card)`
+ box-shadow: 0px 2px 4px rgba(141, 149, 166, 0.1);
+ border-radius: 4px;
+
+ .ant-card-head {
+ border: none;
+ }
+ .ant-card-body {
+ padding-top: 12px;
+ }
+`
+

Update the icon for the dropdown menu.

<Dropdown
overlay={dashboardItemDropdownMenu}
placement="bottomLeft"
trigger={["click"]}
>
- <Button shape="circle" icon="menu" />
+ <Icon type="menu" />
</Dropdown>

And finally, use <StyledCard /> from Styled Components to display the chart’s container.

- <Card
+ <StyledCard
title={title}
+ bordered={false}
style={{
height: "100%",
width: "100%"
}}
extra={<DashboardItemDropdown itemId={itemId} />}
>
{children}
- </Card>
+ </StyledCard>

That was the last part on customization of our React dashboard application. So far we've customized both Explore and the Dashboard pages, as well as the query builder and the charts.

I hope you learned a lot on how to build a custom analytics app, which can either be used internally or embedded into existing applications. If you have questions, feel free to ask them in this Slack community.

In the next and final part, we'll learn how to deploy our application.

Deployment

Deploy Cube API

There are multiple ways you can deploy a Cube API; you can learn more about them here in the docs. In this tutorial, we'll deploy it on Heroku.

The tutorial assumes that you have a free Heroku account. You'd also need a Heroku CLI; you can learn how to install it here.

First, let's create a new Heroku app. Run the following command inside your Cube project folder.

$ heroku create react-dashboard-api

We also need to provide credentials to access the database. I assume you have your database already deployed and externally accessible.

$ heroku config:set \
CUBEJS_DB_TYPE=postgres \
CUBEJS_DB_HOST=<YOUR-DB-HOST> \
CUBEJS_DB_NAME=<YOUR-DB-NAME> \
CUBEJS_DB_USER=<YOUR-DB-USER> \
CUBEJS_DB_PASS=<YOUR-DB-PASSWORD> \
CUBEJS_API_SECRET=<YOUR-SECRET> \
--app react-dashboard-api

Then, we need to create two files for Docker. The first file, Dockerfile, describes how to build a Docker image. Add these contents:

FROM cubejs/cube:latest
COPY . .

The second file, .dockerignore, provides a list of files to be excluded from the image. Add these patterns:

node_modules
npm-debug.log
dashboard-app
.env

Now we need to build the image, push it to the Heroku Container Registry, and release it to our app:

$ heroku container:login
$ heroku container:push web --app react-dashboard-api
$ heroku container:release web --app react-dashboard-api

Let's also provision a free Redis server provided by Heroku:

$ heroku addons:create heroku-redis:hobby-dev --app react-dashboard-api

Great! You can run the heroku open --app react-dashboard-api command to open your Cube API and see this message in your browser:

Cube server is running in production mode.

Deploy React Dashboard

Since our frontend application is just a static app, it easy to build and deploy. Same as for the backend, there are multiple ways you can deploy it. You can serve it with your favorite HTTP server or just select one of the popular cloud providers. We'll use Netlify in this tutorial.

Also, we need to set Cube API URL to the newly created Heroku app URL. In the src/App.js file, change this line:

- const API_URL = "http://localhost:4000";
+ const API_URL = "https://react-dashboard-api.herokuapp.com";

Next, install Netlify CLI.

$ npm install netlify-cli -g

Then, we need to run a build command inside our dashboard-app. This command creates an optimized build for production and puts it into a build folder.

$ npm run build

Finally, we are ready to deploy our dashboard to Netlify; just run the following command to do so.

$ netlify deploy

Follow the command line prompts and choose yes for a new project and build as your deploy folder.

That’s it! You can copy a link from your command line and check your dashboard live!

Congratulations on completing this guide! 🎉

I’d love to hear from you about your experience following this guide. Please send any comments or feedback you might have in this Slack Community. Thank you and we hope you found this guide helpful!