DEV Community

Cover image for A Github PR Dashboard with ReactJS and Material UI
Ajay Jarhad for Canonic Inc.

Posted on • Edited on

A Github PR Dashboard with ReactJS and Material UI

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

  1. Setting up React

    Let's begin by creating a boilerplate using the create-react-app

    npx create-react-app github-pr-dashboard
    
  2. 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
    
  3. 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 inside src called components, then create another directory inside of it called Container. Create an index.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 removing App.js in the process.

    Step 3

  4. Create a List component

    This component will display the PR details such as title, username etc. Create another component named List and create an index.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 into Container 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

    Step 4

  5. 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

    Step 5

    ˳˳˳Now all the components that will display the data is ready. It's time to integrate the backend.

  6. 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)

    Step 6


Backend integration with GraphQL

Let's now integrate! Now that we have our APIs ready, let's move on by installing GraphQL packages.

  1. Install GraphQL packages

    To pull our data from the backend, we will need two packages - Apollo Client and GraphQL

    yarn add @apollo/client graphql
    
  2. 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

  3. Query the data

    Let's define our queries inside src/gql directory in a file called query.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

  4. 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

    Step 10

  5. 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 the data in order to fetch username,profile picture, the labels associated with the PR. Modify List 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 with remarkGfm 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

    Step 11

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)

Collapse
 
kwarunek profile image
kwarunek

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.