Github issues viewer

Let's use ConanJs to build a realistic app that browses a remote repository

Introduction

We used this example to experiment with defining different isolated states and show how they can be used together when different asynchronous calls are needed. In this section we have only shown the code in Typescript.

Creating the Conan states

We have divided the data handled by the app into four independent states:

The remote repository connection details is called repoState$:

export const repoState$: RepoState = 
        Conan.light<RepoData>('repo', {org: "rails", repo: "rails", page: 1})

The specific repository data information retrieved is modelled in repoDetailsState$ :

export interface RepoDetailsData {
    openIssuesCount: number
    error: string | null
}

export const repoDetailsState$: RepoDetailsState = Conan.state<RepoDetailsData>({
    name: 'repo-details',
    initialData: {openIssuesCount: -1, error: null},
    reducers: repoDetailsReducersFn,
    actions: repoDetailsActionsFn
});

The issues data is modelled in issuesState$:

export type IssuesState = ConanState<IssuesData, IssuesActions>;

export interface IssuesData {
    issuesByNumber: Record<number, Issue>;
    issues: Issue[];
    issueId?: number;
    displayType: 'issues' | 'comments';
}

export const issuesState$: IssuesState = Conan.state<IssuesData, IssuesReducers, IssuesActions>({
    name: 'issues',
    initialData: {
        issuesByNumber: {} as Record<number, Issue>,
        issues: [],
        displayType: "issues"
    },
    reducers: issuesReducersFn,
    actions: issueActionsFn
})

and finally the issues comments is modelled in issuesCommentsState$:

export interface IssuesCommentsData {
    commentsByIssue: Record<number, IssueComment[] | undefined>
}

export type IssuesCommentsState = ConanState<IssuesCommentsData, IssuesCommentsActions>;

export const issuesCommentsState$: IssuesCommentsState = Conan.state<IssuesCommentsData, IssuesCommentsReducersFn, IssuesCommentsActions>({
    name: 'issues-comments',
    initialData: {
        commentsByIssue: {} as Record<number, IssueComment[]>,
    },
    reducers: issuesCommentsReducers,
    actions: issueCommentsActionsFn
})

Adding async operations

In this app we need to fetch the issues of a given repository, fetch the information of that same repository and finally retrieve the comments of a given issue. We have implemented this logic in a service called IssuesServiceImpl which uses ConanJs Asap to implement the asynchronous calls:

export interface IssuesService {
    fetch(repo: string, org: string, page: number): Asap<Issue[]>;

    fetchComments(commentsUrl: string): Asap<IssueComment[]>;

    fetchRepoDetails(org: string, repo: string): Asap<RepoDetails>;
}

export class IssuesServiceImpl implements IssuesService {
    fetch(repo: string, org: string, page: number = 1): Asap<Issue[]> {
        return Asaps.fetch<Issue[]>(`https://api.github.com/repos/${org}/${repo}/issues?per_page=25&page=${page}`);
    }

    fetchComments(commentsUrl: string): Asap<IssueComment[]> {
        return Asaps.fetch<IssueComment[]>(commentsUrl);
    }

    fetchRepoDetails(org: string, repo: string): Asap<RepoDetails> {
        return Asaps.fetch(`https://api.github.com/repos/${org}/${repo}`);
    }
}

Adding DI dependencies

We have made these states and the service available to all the app via ConanJs DI:

interface AuxDependencies {
    issuesService: IssuesService
}

export let diContext = DiContextFactory.createContext<App, AuxDependencies>(
    {
        issuesCommentsState: issuesCommentsState$,
        issuesState: issuesState$,
        repoState: repoState$,
        repoDetailsState: repoDetailsState$
    }, {
        issuesService: IssuesServiceImpl
    }
);

export interface App {
    issuesState: IssuesState;
    issuesCommentsState: IssuesCommentsState;
    repoState: RepoState;
    repoDetailsState: RepoDetailsState;
}

Fetching the issues

We need to implement an async call to retrieve the issues of the repository given. We can see how that is done in the actions passed to issuesState$:

export interface IssuesActions {
    fetch(repo: string, org: string, page: number): Asap<IssuesData>;

    fetchIssue(issueId: number): IssuesData;

    showIssues(): IssuesData;
}


export const issueActionsFn: ActionsFn<IssuesData, IssuesReducers, IssuesActions> = thread => ({
    fetch(repo, org, page): Asap<IssuesData> {
        return thread.monitor(
            diContext.issuesService.fetch(repo, org, page).catch(() => thread.reducers.$fetch([])),
            (issues, reducers) => reducers.$fetch(issues as Issue[]),
            'fetch',
            [repo, org, page]
        )
    },
    fetchIssue(issueId: number): IssuesData {
        return thread.reducers.$fetchIssue(issueId);
    },
    showIssues(): IssuesData {
        return thread.reducers.$switchDisplay("issues");
    }
})

The fist action fetch has the async call, so it's wrapped with thread.monitor. The other 2 actions are synchronous, so they can just call the reducer

we can access the issuesService from anywhere, since it's defined in ConanJs DI

Fetching the repository information

We need another async call to retrieve the repository information, but this time bound to the repoDetailsState$. This state just has an action passed in, and it will look like:

export interface RepoDetailsActions {
    fetchRepoDetails(repo, org): Asap<RepoDetailsData>;
}

export const repoDetailsActionsFn: ActionsFn<RepoDetailsData, RepoDetailsReducers, RepoDetailsActions> = thread => ({
    fetchRepoDetails(repo, org): Asap<RepoDetailsData> {
        return thread.monitor(
            diContext.issuesService.fetchRepoDetails(repo, org).catch(() => thread.reducers.$fetchRepoDetails(-1, "error loading")),
            (repoDetails, reducers) => reducers.$fetchRepoDetails(repoDetails.open_issues_count, ""),
            'fetchRepoDetails',
            [repo, org]
        )
    }
});

Fetching comments

Our last async call will happen when the user selects to open an issue, and we need to show the comments. So the issuesCommentsState$ will have an action that looks like:

export interface IssuesCommentsActions {
    fetchComments(issue: Issue): Asap<IssuesCommentsData>;
}

export const issueCommentsActionsFn: ActionsFn<IssuesCommentsData, IssuesCommentsReducersFn, IssuesCommentsActions> = thread => ({
    fetchComments(issue: Issue): Asap<IssuesCommentsData> {
        return thread.monitor(
            diContext.issuesService.fetchComments(issue.comments_url).catch(() => thread.reducers.$fetch([])),
            (comments, reducers) => reducers.$fetchComments(issue.id, comments as IssueComment[]),
            'fetchComments',
            issue.comments_url
        )
    }
})

Displaying the issues pages

Let's now see how we can display the issues and the repository information. The following fragment belongs to the functional component IssuesListPage:

<div id="issue-list-page">
    {repoDetailsState$.connectMap<HeaderProps>(
        IssuesPageHeader,
        data => ({
            org: org,
            repo: repo,
            openIssuesCount: data.openIssuesCount
        })
    )
    }
    {diContext.issuesState.connect(IssuesList)}
</div>

It uses connectMap to link repoDetailsState$ with the component IssuesPageHeader, and the DI context to fully connect issuesState with the component IssuesList.

Displaying the comments

The component IssueDetailsPage uses a ConanJs hook useConanState to connect with the state issuesCommentsState$ and retrieve the issues in its own useEffect:

const [commentsState] = useConanState<IssuesCommentsData, IssuesCommentsActions>(issuesCommentsState$);

useEffect(() => {
    if (issue) {
        fetchComments(issue)
    }
}, []);

const comments = commentsState.commentsByIssue[issue.id];
let renderedComments;
if (comments) {
    renderedComments = <IssueComments issue={issue} comments={comments}/>
}

Getting the code

The full code for this example is available at

or the code sandbox below, for more details on this ConanJs feature.

Code sandbox

Last updated