Tutorial - Github PR List

In this tutorial we will be building an app that lists pull requests on Github for a repo. You can find the completed app and code in the following links.

Edit Github PR List

Create Prodo App

The easiest way to get started with Prodo is with create-prodo-app. In this tutorial we will use TypeScript and Yarn.

Run the following command to create a Prodo app in the github-prs directory.

yarn create prodo-app github-prs --typescript

This will create the directory structure.

github-prs/
├── README.md
├── package.json
├── public
│   ├── index.html
│   ├── favicon.ico
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── index.tsx
│   ├── model.ts
│   └── styles.css
│   ├── components
│   │   └── Header.tsx
│   ├── pages
│   │   ├── Home.tsx
│   │   └── NotFound.tsx
├── tests
│   └── App.test.tsx
├── tsconfig.json
├── webpack.config.js

Navigate to the app’s directory and install the dependencies.

cd github-prs
yarn

After this completes, you can start the app by running

yarn start

Open up localhost:8080 and you should see this:

Prodo template first launch

The app created with create-prodo-app (CPA) is fairly minimal, yet a good starting point when creating simple or complex Prodo apps. It includes two Prodo plugins: logger and route, which we will look at in the next section.

CPA uses Webpack to bundle your app and is setup with hot module replacement (HMR). This means when you make a change, your app will update without the page refreshing.

Let’s change the header in src/components/Header.tsx.

import * as React from "react";
import { Link } from "@prodo/route";
const Header = () => (
<header>
<div className="container">
<Link to="/">Github PR List</Link>
</div>
</header>
);
export default Header;

The page should update automatically with the new text.

Styling

This tutorial is not about CSS. To use the same styles as this tutorial, copy this file to src/styles.css.

Specifying the model

In a Prodo app your state is stored in a global store which is accessed by your components and updated by your actions. The type of this state is defined in a model in src/model.ts. Plugins can also be added to the model to extend the functionality.

The model is created with createModel using the State type. Initially the app has no state. We also add the logger and route plugins. The logger plugin logs useful information to the console. The route plugin gives us client side routing.

Adding some state

In our app we want to store a list of pull requests per repo. We can do that with:

import { createModel } from "@prodo/core";
import loggerPlugin from "@prodo/logger";
import routePlugin from "@prodo/route";
export interface IPullRequest {
id: number;
prNumber: number;
title: string;
url: string;
author: string;
authorImage: string;
createdAt: string;
}
export interface State {
pullRequests: { [key: string]: IPullRequest[] };
}
export const model = createModel<State>()
.with(loggerPlugin)
.with(routePlugin);
export const { state, watch, dispatch } = model.ctx;

The list of pull requests will be keyed by a string that matches the owner/repo format.

When creating the store in src/index.tsx we now need to initialize the pullRequests in our state.

// ...
const history = createBrowserHistory();
const { Provider } = model.createStore({
logger: true,
route: {
history,
},
initState: {
pullRequests: {},
},
});
// ...

Routing

Our app will have two routes, one for searching and one for a repo. CPA includes a pre-configured route plugin so all we need to do is define the routes in src/App.tsx.

import * as React from "react";
import { Switch, Route } from "@prodo/route";
import Header from "./components/Header";
import NotFound from "./pages/NotFound";
import Home from "./pages/Home";
import Repo from "./pages/Repo";
const App = () => (
<div className="app">
<Header />
<div className="container">
<Switch>
<Route exact path="/" component={Home} />
<Route path="/:owner/:repo" component={Repo} />
<Route component={NotFound} />
</Switch>
</div>
</div>
);
export default App;

Route parameters get passed to components as props. Create a Repo page in src/pages/Repo.tsx with the following contents:

import * as React from "react";
import { Link } from "@prodo/route";
export interface Props {
owner: string;
repo: string;
}
const Repo: React.FC<Props> = ({ owner, repo }) => (
<div className="repo">
<h1>
{owner}/{repo}
</h1>
<Link to="/">Home</Link>
</div>
);
export default Repo;

and change src/pages/Home.tsx to:

import * as React from "react";
import { Link } from "@prodo/route";
const Home = () => (
<div className="home">
<h1>Home</h1>
<Link to="/prodo-ai/prodo">prodo-ai/prodo</Link>
</div>
);
export default Home;

You should now be able to navigate between the home page and a repo page.

route plugin navigation

Github API

The next step is to connect the Github REST API. You could also you the GraphQL API, however, that requires an authentication token. The API route we are interested in is list pull requests.

Create the file src/api.ts with the following contents:

import { IPullRequest } from "./model";
export const getPullRequests = async (
owner: string,
repo: string,
): Promise<IPullRequest[]> => {
const data = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls`,
).then(res => res.json());
if (data.message != null) {
throw new Error(data.message);
}
const pullRequests: IPullRequest[] = data.map((d: any) => ({
id: d.id,
prNumber: d.number,
title: d.title,
url: d.html_url,
author: d.user.login,
authorImage: d.user.avatar_url,
createdAt: d.created_at,
}));
return pullRequests;
};

This function returns a Promise of a list of pull requests.

Creating actions

We can create a Prodo action to fetch the pull requests and add them to our app’s state. In a file called src/actions.ts place the following:

import { state } from "./model";
import * as api from "./api";
export const loadPullRequests = async (owner: string, repo: string) => {
const key = `${owner}/${repo}`;
state.pullRequests[key] = await api.getPullRequests(owner, repo);
};

All this does is fetch the pull requests from Github and place them in our state. Prodo actions can be async so we don’t have to worry about using async/await.

Creating components

We’ve setup the internals of our app, but still need a way for the user to interact with it.

To search for a repo we can have a simple form with an input field and button. The contents of the input will be stored in React local state. When the button is pressed, we will navigate to the repo page. To do this we use the push action that comes from the @prodo/route plugin.

Change src/pages/Home.tsx to:

import * as React from "react";
import { dispatch } from "../model";
import { push } from "@prodo/route";
const Home = () => {
const [search, setSearch] = React.useState("");
return (
<div className="home">
<h1>Enter a Github owner and repo</h1>
<form
onSubmit={e => {
e.preventDefault();
dispatch(push)(search);
}}
>
<input
placeholder="owner/repo"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<button>Go</button>
</form>
</div>
);
};
export default Home;

If you navigate to localhost:8080 you should see something like:

pull request list home page

Great! Now when you enter prodo-ai/prodo in the text field and press enter you will be navigated to the repo page.

Repo list

The final step is to fetch the pull requests and render the results.

We can fetch the pull requests for the owner and repo with a useEffect hook.

import * as React from "react";
import { Link } from "@prodo/route";
import { state, watch, dispatch } from "../model";
import * as actions from "../actions";
export interface Props {
owner: string;
repo: string;
}
const Repo: React.FC<Props> = ({ owner, repo }) => {
React.useEffect(() => {
dispatch(actions.loadPullRequests)(owner, repo);
});
return (
<div className="repo">
<h1>
{owner}/{repo}
</h1>
<Link to="/">Home</Link>
</div>
);
};
export default Repo;

If you look in the console, you will see that our state has been populated with the pull requests.

fetching pull requests action

Finally, we have to render the list of pull requests. We can use watch to subscribe the component to part of the state.

import * as React from "react";
import { state, watch, dispatch, IPullRequest } from "../model";
import * as actions from "../actions";
const formatDate = (dateString: string): string => {
const created = new Date(dateString);
return `${created.getDay()}/${created.getMonth()}/${created.getFullYear()}`;
};
const PullRequest: React.FC<{ pullRequest: IPullRequest }> = ({
pullRequest,
}) => {
return (
<a
href={pullRequest.url}
target="_blank"
className="none"
rel="noopener noreferrer"
>
<div className="pull-request">
<img src={pullRequest.authorImage} />
<div>
<h2>{pullRequest.title}</h2>
<span className="pull-request__detail">
#{pullRequest.prNumber} opened {formatDate(pullRequest.createdAt)}{" "}
by {pullRequest.author}
</span>
</div>
</div>
</a>
);
};
export interface Props {
owner: string;
repo: string;
}
const Repo: React.FC<Props> = ({ owner, repo }) => {
React.useEffect(() => {
dispatch(actions.loadPullRequests)(owner, repo);
}, [owner, repo]);
const pullRequests = watch(state.pullRequests[`${owner}/${repo}`]);
return (
<div>
<h1>
Pull Requests for{" "}
<a className="none" href={`https://github.com/${owner}/${repo}/pulls`}>
<em>
{owner}/{repo}
</em>
</a>
</h1>
{pullRequests == null ? (
<h1>Loading...</h1>
) : (
<div className="pull-requests">
{pullRequests.map(pr => (
<PullRequest key={pr.id} pullRequest={pr} />
))}
</div>
)}
</div>
);
};
export default Repo;

If you navigate to localhost:8080/facebook/react you should see something like:

pull request list for facebook react

Final result

The app we built can be seen in the following CodeSandbox

Next steps

Our app works, but only for well-formed input. We also do not handle errors when fetching from the API. Try adding error handling to the app.