Dashboards are great for visualization and keeping track of what's going on. Managing a repository for a team can be cumbersome and keeping a track of all the pull requests even so. One potential solution could be the dashboard, dashboards are great for visualization and keeping track of what's going on.
In this article, we will discuss how to create a Github Pull Requests Dashboard using ReactJS and Material UI.
Let's get started
-
Setting up React
Let's begin by creating a boilerplate using the
create-react-app
npx create-react-app github-pr-dashboard
-
Install Material UI
We will be using Material UI as a UI library. It boosts up the speed of development as we won't have to manually write CSS.
yarn add @mui/material @emotion/react @emotion/styled
-
Creating a Container component
We will be using a
Container
component to display the columns corresponding to different stages to the PR. Create a directory insidesrc
called components, then create another directory inside of it calledContainer
. Create anindex.js
file that will contain our display logic.
import React from "react"; import { Card, CardContent, AppBar, Box, Toolbar, Typography, Stack, } from "@mui/material"; import "./style.css"; const Container = () => { return ( <> <Box sx={{ flexGrow: 1 }}> <AppBar position="static"> <Toolbar variant="dense"> <Typography variant="h6" color="inherit" component="div" textAlign="center" width="100%" > Github PR Dashboard </Typography> </Toolbar> </AppBar> </Box> <div className="container"> <Card className="card"> <Stack direction="row" alignItems="center" justifyContent="space-between" > <Typography gutterBottom variant="h5"> Merged </Typography> </Stack> <CardContent className="cardContent"> {/* PRs to be displayed here */} </CardContent> </Card> <Card className="card"> <Stack direction="row" alignItems="center" justifyContent="space-between" > <Typography gutterBottom variant="h5"> In Review </Typography> </Stack> <CardContent className="cardContent"> {/* PRs to be displayed here */} </CardContent> </Card> <Card className="card"> <Stack direction="row" alignItems="center" justifyContent="space-between" > <Typography gutterBottom variant="h5"> Assigned </Typography> </Stack> <CardContent className="cardContent"> {/* PRs to be displayed here */} </CardContent> </Card> <Card className="card"> <Stack direction="row" alignItems="center" justifyContent="space-between" > <Typography gutterBottom variant="h5"> Open </Typography> </Stack> <CardContent className="cardContent"> {/* PRs to be displayed here */} </CardContent> </Card> </div> </> ); }; export default Container;
src/components/Container/Container.js
Create a CSS file as well and name it
style.css
. We will write some custom CSS to display the columns in a 2*2 grid.
.container { width: 100%; display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-evenly; margin: 2rem auto; } .card { width: calc(100% * (1 / 2) - 5rem); height: 400px; margin: 1.3rem; padding: 1rem; } @media only screen and (max-width: 600px) { .card { width: 100%; } } .cardContent { height: 80%; overflow: auto; }
src/components/Container/style.css
Simply import this component in
src/index.js
removingApp.js
in the process. -
Create a List component
This component will display the PR details such as title, username etc. Create another component named
List
and create anindex.js
file inside of it. We will set it up for displaying the desired details.
import React from "react"; import { Avatar, Paper, Typography, Fab } from "@mui/material"; import "./style.css"; const List = () => { return ( <Paper elevation={3} sx={{ padding: 2, margin: 2 }}> <Typography variant="body1" color="text.secondary"> Pull Request 1 </Typography> <Fab variant="extended" size="small"> Bug </Fab> <div className="avatar"> {/* Profile picture will be displayed here */} <Avatar sx={{ width: 24, height: 24 }} /> <Typography variant="body2" sx={{ ml: 1 }}> John Doe </Typography> </div> </Paper> ); }; export default List;
src/components/List/List.js
We will add a bit of custom CSS to align Profile picture and username. Let's create
style.css
.
.avatar { display: flex; align-items: center; margin: 0.4rem 0; }
src/components/List/style.css
We will then import the List component into Container and call it in CardContent.
Import
List
component intoContainer
component and replace it at every/* PRs to be displayed here */
comment.
<CardContent className="cardContent"> <List /> //Calling the List component </CardContent>
src/components/Container/Container.js
-
Create a Modal component
Let's create a modal component to display more details about the PR when clicked on.
import React from "react"; import { Avatar, Typography, Button, Fade, Modal, Box, Backdrop, } from "@mui/material"; export default function Modals() { const [open, setOpen] = React.useState(false); const handleOpen = () => setOpen(true); const handleClose = () => setOpen(false); return ( <div> <Modal aria-labelledby="transition-modal-title" aria-describedby="transition-modal-description" open={open} onClose={handleClose} closeAfterTransition BackdropComponent={Backdrop} BackdropProps={{ timeout: 500, }} > <Fade in={open}> <Box className="box"> <div className="avatar"> {/* Profile picture will be displayed here */} <Avatar sx={{ width: 24, height: 24 }} /> <Typography variant="body2" style={{ marginLeft: 10 }}> John Doe </Typography> </div> <Typography id="transition-modal-title" variant="h4" component="h2"> Pull Request 1 </Typography> <Typography id="transition-modal-description" sx={{ mt: 2, mb: 2 }}> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. </Typography> {/* This will be displayed only if the PR has an assignee assigned */} { <> <Typography variant="h6">Assigned to</Typography> <div style={{ display: "flex", alignItems: "center" }}> {/* Assignee's profile picture will be displayed here */} <Avatar sx={{ width: 24, height: 24 }} /> <Typography variant="body2" style={{ marginLeft: 10 }}> Jane Doe </Typography> </div> </> } <Typography id="transition-modal-description" sx={{ mt: 2 }}> <Button variant="contained" href="#" target="_blank" rel="noreferrer" sx={{ mt: 2 }} > Open PR </Button> </Typography> </Box> </Fade> </Modal> </div> ); }
src/components/Modal/Modal.js
Let's import this component inside the
List
component and define a onClick for handling PR click. We will use props to pass the value for open and close inside modal component.
... ... import Modals from "../Modal"; ... const List = () => { const [isOpen, setIsOpen] = React.useState(); const handleClick = React.useCallback((pr, identifier) => { setIsOpen(true); }, []); return ( <> <Paper elevation={3} sx={{ padding: 2, margin: 2 }} onClick={() => handleClick()} > ... </Paper> {/* we are passing isOpen and setIsOpen states to Modal, to manage open and close modal on click */} <Modals open={isOpen} setOpen={setIsOpen} /> </> ); }; export default List;
src/components/List/List.js
We have to go back to Modal, to accept the new props.
... ... export default function Modals({ setOpen, open }) { const handleClose = React.useCallback(() => { setOpen(!open); }, [open, setOpen]); return ( <div> ... </div> ); }
src/components/Modal/Modal.js
We will also need CSS to correctly display the Modal, create a style.css file inside Modal directory and then import in the component.
.box { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 50%; height: 40%; overflow: auto; background: #fff; border: 2px solid #000; box-shadow: 24px; padding: 4rem; } .avatars { display: flex; align-items: center; margin: 1rem 0 2rem 0; }
src/components/Modal/style.css
˳˳˳Now all the components that will display the data is ready. It's time to integrate the backend.
-
Time to get your APIs ready!
Getting your APIs for Github Dashboard comes at real ease. All you have to do is clone this production-ready project on Canonic and you're done. It will provide you with the backend, APIs, and documentation you need for integration, without writing any code. Just make sure you add your own Github's credentials (You don't need to enter the credentials if you are fetching open-source repository)
Backend integration with GraphQL
Let's now integrate! Now that we have our APIs ready, let's move on by installing GraphQL packages.
-
Install GraphQL packages
To pull our data from the backend, we will need two packages - Apollo Client and GraphQL
yarn add @apollo/client graphql
-
Configure GraphQL to communicate with backend
Configure the Apollo Client in the project directory, inside
index.js
configure your apollo client so it would communicate with the backend.Note to replace the
uri
with the one you'll get from Canonic.import React from "react"; import ReactDOM from "react-dom"; import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client"; import Container from "./components/Container"; import "./index.css"; const client = new ApolloClient({ uri: "https://github-pr-dashboard-d3d.can.canonic.dev/graphql", cache: new InMemoryCache(), }); ReactDOM.render( <ApolloProvider client={client}> <Container></Container> </ApolloProvider>, document.getElementById("root") );
src/index.js
-
Query the data
Let's define our queries inside
src/gql
directory in a file calledquery.js
.
import { gql } from "@apollo/client"; export const GET_CONTAINERS = gql` query { containers { #Container is on the the table on the Canonic backend title #Title contains the title of the container identifier #Using the identifier to associate the PR with the container. } } `; export const GET_PR = gql` query { pullRequests { #Pull Requests is another table on the Canonic backend github { #Github is the name of our Github integration. title #title will contain PR's title state #state will contain PR's state (etiher 'open' or 'closed') body #body contains the description of the PR draft #draft contains a boolean value indicating if the PR is a draft or not html_url #html_url contains the link to PR on github.com user { #user will hold user's data, here we are only fetching their profile picture link and their username avatar_url #avatar_url has the profile picture link of the user login #login has user's username } assignee { #assignee contains either null means PR has no assignee or return a user's data, here as well we are fetching their profile picture link and their username. avatar_url #avatar_url has the assignee's profile picture link of the user login #login has assignee's username } labels { #labels contains the array of labels. Here we will be just needing name and color name #name has title of the label color #color contains the hexadecimal value of color without a '#' } } } } `;
src/gql/query.js
-
Fetching data from the API
Inside our
Container
component, we execute the query.
import { React, useEffect, useState, useCallback } from "react"; import { useQuery } from "@apollo/client"; ... //Local dependencies import { GET_CONTAINERS } from "../../gql/query"; ... const Container = () => { const { data: containerData } = useQuery(GET_CONTAINERS); // Fetching all 4 of our containers from graphQL const [containers, setContainers] = useState(); //Using this state to populate the containers. useEffect(() => { if (containerData) { setContainers(containerData.containers); //Setting up the container state } }, [containerData]); //Re-rendering state whenever Container data changes // We are using this util function to assign a background color based on the column's identifier. const setColor = useCallback((identifier) => { let color = ""; switch (identifier) { case "merge": color = "#6fbf73"; break; case "pending": color = "#4dabf5"; break; case "assigned": color = "#ffcd38"; break; case "review": color = "#ffa733"; break; default: color = ""; } return color; }, []); return ( <> {/* The Box component by the MaterialUI is responsible for display the header */} <Box sx={{ flexGrow: 1 }}> ... </Box> <div className="container"> {/* Mapping over the container data to display each container and display its corresponding PRs */} {containers && containers.map((containerName, i) => ( <Card className="card" key={i}> {/* The Stack is a Material UI component, it will display a content inside a square box */} <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1, mb: 2, bgcolor: setColor(containerName.identifier), //Here we are calling the setColor function to dynamically assigning a background color based on name/identifier of the container. color: "#FFFFFF", borderRadius: "4px", }} > <Typography gutterBottom variant="h5" sx={{ width: "100%", paddingTop: "0.5rem", textAlign: "center", }} > {containerName.title} </Typography> </Stack> <CardContent className="cardContent"> {/* There are in total 4 identifier created on the backend (merge,review,assigned,draft) based on that associating the PRs */} </CardContent> </Card> ))} </div> </> ); }; export default Container;
src/components/Container/Container.js
-
Passing data as props
We will be passing the data for the PRs from
Container
component.
... //Local dependencies import List from "../List"; import { GET_CONTAINERS, GET_PR } from "../../gql/query"; ... const Container = () => { ... const { loading, data: prData } = useQuery(GET_PR); ... ... const pullRequests = useMemo( () => prData?.pullRequests?.map((item) => item.github)?.[0] || [], [prData?.pullRequests] //Mapping over the data and assigning it to 'pullRequests' variable ); const opened = useMemo( () => pullRequests.filter((item) => item.state === "open"), [pullRequests] // Filtering and populating all the PRs with 'open' status ); const closed = useMemo( () => pullRequests.filter((item) => item.state === "closed").slice(0, 15), [pullRequests] // Filtering and populating all the PRs with 'closed' status ); const draft = useMemo( () => pullRequests.filter((item) => item.draft === true), [pullRequests] // Filtering and populating all the draft PRs ); const assigned = useMemo( () => pullRequests.filter( (item) => item.assignee !== null && item.assignee.login.length > 1 ), [pullRequests] // Filtering and populating all the PRs who has assignee. ); ... return ( <> <Box sx={{ flexGrow: 1 }}> <AppBar position="static"> <Toolbar variant="dense"> <Typography variant="h6" color="inherit" component="div" textAlign="center" width="100%" > Github PR Dashboard </Typography> </Toolbar> </AppBar> </Box> <div className="container"> {loading && ( <Typography gutterBottom variant="h5" component="div" marginBottom="5rem" > Loading...... </Typography> )} {!loading && containers && containers.map((containerName, i) => ( <Card className="card" key={i}> ... </Stack> <CardContent className="cardContent"> {/* There are in total 4 identifier created on the backend (merge,review,assigned,draft) based on that associating the PRs */} {containerName.identifier === "merge" && closed && ( <List data={closed} key={i} identifier={containerName.identifier} /> )} {containerName.identifier === "review" && draft && ( <List data={draft} key={i} identifier={containerName.identifier} /> )} {containerName.identifier === "assigned" && assigned && ( <List data={assigned} key={i} identifier={containerName.identifier} /> )} {containerName.identifier === "pending" && opened && ( <List data={opened} key={i} identifier={containerName.identifier} /> )} </CardContent> </Card> ))} </div> </> ); }; export default Container;
src/components/Container/Container.js
Now we have received the
data
as a prop, we will map over thedata
in order to fetch username,profile picture, the labels associated with the PR. ModifyList
component for that.
import React, { useState, useCallback } from "react"; ... ... const List = ({ data, identifier }) => { const [isOpen, setIsOpen] = useState(); const [modalData, setModalData] = useState(); const [containersName, setContainersName] = useState(); const handleClick = useCallback((pr, identifier) => { setIsOpen(true); setModalData(pr); setContainersName(identifier); }, []); return ( <> {data.map((item, i) => ( <Paper elevation={3} sx={{ padding: 2, margin: 2, cursor: "pointer" }} key={i} onClick={() => handleClick(item, identifier)} > ... </Paper> ))} <Modals open={isOpen} setOpen={setIsOpen} pr={modalData} containerName={containersName} /> </> ...
We are receiving the PR body text in markdown format, we will use
ReactMarkdown
along withremarkGfm
which helps to render Github flavoured markdown.
yarn add react-markdown remark-gfm
Let's edit
Modal
component to account for props data.
... import { ..., Fab } from "@mui/material"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; ... export default function Modals({ pr, containerName, open, setOpen }) { ... return ( <div> <Modal ... > {pr && ( <Fade in={open}> <Box className="box"> <Fab variant="extended" size="small" sx={{ mb: 2 }}> {containerName} </Fab> <div className="avatars"> {/* Displays user's profile picture */} <Avatar alt={pr.user.login} src={pr.user.avatar_url} sx={{ width: 24, height: 24 }} /> <Typography variant="body2" style={{ marginLeft: 10 }}> {/* Displays user's username */} {pr.user.login} </Typography> </div> <Typography id="transition-modal-title" variant="h4" component="h2" > {/* Displays the title of the PR */} {pr.title} </Typography> <Typography id="transition-modal-description" sx={{ mt: 2, mb: 2 }} > {/* Displays the PR's body content. Using react-markdown to display markdown */} <ReactMarkdown children={pr.body} remarkPlugins={[remarkGfm]} /> </Typography> {/* This will be displayed only if the PR has an assignee assigned */} {pr.assignee && pr.assignee.login.length > 1 && ( <> <Typography variant="h6">Assigned to</Typography> <div style={{ display: "flex", alignItems: "center" }}> {/* Displays assignee's profile picture */} <Avatar alt={pr.assignee.login} src={pr.assignee.avatar_url} sx={{ width: 24, height: 24 }} /> <Typography variant="body2" style={{ marginLeft: 10 }}> {/* Displays assignee's username */} {pr.assignee.login} </Typography> </div> </> )} <Typography id="transition-modal-description" sx={{ mt: 2 }}> { // Displays the hyperlink of PR <Button variant="contained" href={pr.html_url} target="_blank" rel="noreferrer" sx={{ mt: 2 }} > Open PR </Button> } </Typography> </Box> </Fade> )} ...
src/components/Modal/Modal.js
Voila! we are done.
Live Demo
Sample Code on Github.
Conclusion:
This dashboard helps you gain visibility into PRs for a particular repository.
If you want, you can also duplicate this project from Canonic's sample app and easily get started by customizing it as per your experience. Check it out here.
You can also check out our other guides here.
Join us on discord to discuss or share with our community. Write to us for any support requests at support@canonic.dev. Check out our website to know more about Canonic.
Top comments (1)
Due to limitations in the GitHub UI (such as difficulty tracking or finding pull requests after a review from a team member), I created a similar dashboard. It’s entirely client-side, built with pure JavaScript, and provides a dead-simple approach to finding pull requests in an organization. Check it out here: github.com/atfu-tech/gh-dashboard.