Adding Frontend Features With Next.js, Chakra UI, & Apollo Client

WL

William Lyon / March 21, 2021

12 min read

This is the sixth post in a series about building a podcast application using GRANDstack. Check out the previous episodes here:

In this post we continue adding frontend features to GRANDcast.FM, our podcast application built with GRANDstack using Chakra UI, Next.js, and GraphQL with Apollo Client. Our goal this time is to add a playlist view and a podcast search results view. We worked through adding these features on a recent episode of the Neo4j live stream:

Building The Playlist View#

The first frontend feature we want to build is our playlist view. We want the user to be able to:

  • See all their playlists
  • Select a playlist and view the podcast episodes assigned to that playlist
  • Create new playlists

Here's what the final playlist view will look like:

In the previous post we created an Episode component that can display episode details, allow the user to play the episode, and add episodes to playlists, so we'll reuse that component in our playlist view.

In the playlist view we'll make use of the following Chakra UI components:

  • Flex - Useful for creating responsive layout, renders a div with display: flex.
  • Popover - A floating dialog that can be triggered by a user interaction. We'll use a Popover to input playlist name when creating a new playlist.
  • VStack - A layout component for stacking elements together vertically with uniform spacing. We'll use VStack for rendering a list of podcast Episode components in the selected playlist.

Fetching Data With Apollo Client#

First, we need to find the playlists that belong to the currently authenticated user. We'll populate a select input with the names of these playlists for the user to select.

In Episode 3 we added functionality in our GraphQL API for handling user playlists. Using the authentication token in the request header (see Episode 4 for how we handle authentication in Next.js) our GraphQL API will find all playlists for the user, as well as podcast episodes assigned to each playlist.

Using the playlists GraphQL query field we can find all playlists and their associated podcast episodes for the currently authenticated user. We'll populate a select dropdown input with the name of each playlist and update a React state variable selectedPlaylist when the user selects a playlist from the select input.

import { gql, useQuery } from '@apollo/client';

const GET_PLAYLISTS = gql`
  {
    playlists {
      name
      episodes {
        id
        title
        audio
        summary
        image
        pubDate {
          day
          month
          year
        }
        podcast {
          title
          image
        }
      }
    }
  }
`;

export default function Playlists() {
  const { data } = useQuery(GET_PLAYLISTS);
  const [selectedPlaylist, setSelectedPlaylist] = useState('');

  return (
    <Container>
      {!isSignedIn() && <SignIn />}
      {isSignedIn() && (
        <div>
          <FormControl id="playlists">
            <FormLabel>Playlists</FormLabel>
            <Flex>
              <Select
                placeholder="Select playlist"
                onChange={(e) => setSelectedPlaylist(e.target.value)}
              >
                {data?.playlists?.map((p) => {
                  return (
                    <option key={p.name} value={p.name}>
                      {p.name}
                    </option>
                  );
                })}
              </Select>
            </Flex>
          </FormControl>
        </div>
      )}
    </Container>
  );
}

That takes care of the initial requirement of showing the user their playlists. Next, we add functionality for the user to create new playlists.

Creating A New Playlist#

In the GraphQL API we'll use the createPlaylist mutation to add a new playlist for the user. The mutation is implemented using a @cypher directive with the logic to create the playlist node in the database and connect is to the user node with an OWNS relationship. See Episode 3 for how we've implemented that in the GraphQL API.

Let's add a button next to the playlist select input that when clicked will trigger a popover, prompting the user to enter the name for the new playlist they'd like to create. The popover will also include a button to create the playlist, which will trigger Apollo Client to execute the createPlaylist GraphQL mutation, passing the playlist name as a variable.

const CREATE_PLAYLIST = gql`
  mutation createNewPlaylist($playlistName: String!) {
    createPlaylist(name: $playlistName) {
      name
    }
  }
`;

We'll also need to update the Apollo Client cache to include the name of the new playlist so it will immediately show up in the playlist select input. This concept of immediately updating the frontend view is called "optimistic UI" - we update the UI optimistically, assuming the GraphQL call to the backend will successfully be executed as intended and update the view to represent the state after the data is updated.

We handle this by passing an update function when calling the mutation which will first read from the Apollo Client cache, add the new playlist to the list of cached playlists, and then update the cache including the new playlist.

<Popover>
  <PopoverTrigger>
    <Button ml={4}>
      <AddIcon />
    </Button>
  </PopoverTrigger>
  <PopoverContent>
    <PopoverArrow />
    <PopoverCloseButton />
    <PopoverHeader>Create new playlist</PopoverHeader>
    <PopoverBody>
      <FormControl id="newplaylist">
        <FormLabel>New playlist</FormLabel>
        <Input type="text" onChange={(e) => setNewPlaylist(e.target.value)} />
        <Button
          mt={4}
          onClick={() =>
            createPlaylist({
              variables: { playlistName: newPlaylist },
              update: (proxy) => {
                const data = proxy.readQuery({
                  query: GET_PLAYLISTS
                });
                proxy.writeQuery({
                  query: GET_PLAYLISTS,
                  data: {
                    playlists: [
                      ...data.playlists,
                      {
                        __typename: 'Playlist',
                        name: newPlaylist
                      }
                    ]
                  }
                });
              }
            })
          }
        >
          Create
        </Button>
      </FormControl>
    </PopoverBody>
  </PopoverContent>
</Popover>

The user can now view their playlists, create a new playlist, and immediately see their new playlist in the select input without refreshing the page and triggering a refresh of the data from the backend GraphQL server. The next requirement we need to address is showing the episodes for the selected playlist.

Filtering For The Selected Playlist#

When the user selects an episode from the select input, the selectedPlaylists React state variable is updated with the name of the selected playlist. Let's compute the filtered playlist array of episodes for that playlist.

const filteredPlaylist = data?.playlists.filter((p) => {
  return p.name === selectedPlaylist;
})[0];

Now that filteredPlaylist is an array of episodes for the currently selected playlist we can use a VStack to render a list of Episode components, showing the user the episodes they've assigned to that playlist.

Because filteredPlaylist is dependent on our selectedPlaylist React state variable, when the user selects a new playlist from the select input the filtered playlist will be recomputed and the view updated.

<VStack spacing={4}>
  {filteredPlaylist?.episodes?.map((e) => {
    return <Episode key={e.id} episode={e} playlists={data.playlists} />;
  })}
</VStack>

Because we already built the functionality for adding an episode to a playlist in the Episode component that logic is already available.

Putting It All Together: Playlist View#

Here's what the playlist view code looks like now. We make use of the file-based router in Next.js - creating pages/playlists.js will create a new route at /playlists in our application.

pages/playlists.js
import { useState } from 'react';
import { gql, useMutation, useQuery } from '@apollo/client';
import Episode from '../components/Episode';
import SignIn from '../components/SignIn';
import { useAuth } from '../lib/auth';
import {
  VStack,
  FormControl,
  FormLabel,
  FormHelperText,
  Select,
  Container,
  Popover,
  PopoverTrigger,
  PopoverHeader,
  PopoverBody,
  PopoverContent,
  PopoverArrow,
  PopoverCloseButton,
  Button,
  Flex,
  Input
} from '@chakra-ui/react';

import { AddIcon } from '@chakra-ui/icons';

const CREATE_PLAYLIST = gql`
  mutation createNewPlaylist($playlistName: String!) {
    createPlaylist(name: $playlistName) {
      name
    }
  }
`;

const GET_PLAYLISTS = gql`
  {
    playlists {
      name
      episodes {
        id
        title
        audio
        summary
        image
        pubDate {
          day
          month
          year
        }
        podcast {
          title
          image
        }
      }
    }
  }
`;

export default function Playlists() {
  const [createPlaylist] = useMutation(CREATE_PLAYLIST);
  const [newPlaylist, setNewPlaylist] = useState('');
  const { data } = useQuery(GET_PLAYLISTS);

  const [selectedPlaylist, setSelectedPlaylist] = useState('');
  const isPopoverOpen = useState(false);

  const { isSignedIn } = useAuth();

  // show signin form if not authenticated
  // create new playlist
  // dropdown to select playlist
  // show episodes for each playlist when selected

  const filteredPlaylist = data?.playlists.filter((p) => {
    return p.name === selectedPlaylist;
  })[0];

  return (
    <Container>
      {!isSignedIn() && <SignIn />}
      {isSignedIn() && (
        <div>
          <FormControl id="playlists">
            <FormLabel>Playlists</FormLabel>
            <Flex>
              <Select
                placeholder="Select playlist"
                onChange={(e) => setSelectedPlaylist(e.target.value)}
              >
                {data?.playlists?.map((p) => {
                  return (
                    <option key={p.name} value={p.name}>
                      {p.name}
                    </option>
                  );
                })}
              </Select>
              <Popover>
                <PopoverTrigger>
                  <Button ml={4}>
                    <AddIcon />
                  </Button>
                </PopoverTrigger>
                <PopoverContent>
                  <PopoverArrow />
                  <PopoverCloseButton />
                  <PopoverHeader>Create new playlist</PopoverHeader>
                  <PopoverBody>
                    <FormControl id="newplaylist">
                      <FormLabel>New playlist</FormLabel>
                      <Input
                        type="text"
                        onChange={(e) => setNewPlaylist(e.target.value)}
                      />
                      <Button
                        mt={4}
                        onClick={() =>
                          createPlaylist({
                            variables: { playlistName: newPlaylist },
                            update: (proxy) => {
                              const data = proxy.readQuery({
                                query: GET_PLAYLISTS
                              });
                              proxy.writeQuery({
                                query: GET_PLAYLISTS,
                                data: {
                                  playlists: [
                                    ...data.playlists,
                                    {
                                      __typename: 'Playlist',
                                      name: newPlaylist
                                    }
                                  ]
                                }
                              });
                            }
                          })
                        }
                      >
                        Create
                      </Button>
                    </FormControl>
                  </PopoverBody>
                </PopoverContent>
              </Popover>
            </Flex>
            <FormHelperText>Foobar</FormHelperText>
          </FormControl>
          <VStack spacing={4}>
            {filteredPlaylist?.episodes?.map((e) => {
              return (
                <Episode key={e.id} episode={e} playlists={data.playlists} />
              );
            })}
          </VStack>
        </div>
      )}
    </Container>
  );
}

Podcast Search Results View#

The next feature to add is a podcast search results view. We want the user to be able to search for podcasts, view the results, and subscribe to podcasts listed in the search results.

Podcast Search GraphQL Query#

In Episode 1 we built the functionality for searching for podcasts using the Podcast Index REST API. That functionality is exposed in the podcastSearch GraphQL query field.

The Podcast Component#

Similar to the Episode component, let's create a component for displaying podcast details like the title, description, and image. Our Podcast component will also need to include the logic for subscribing the user to the podcast.

We'll make use of the following Chakra UI components:

  • Box - Useful for creating responsive layouts and grouping elements.
  • Flex - Similar to Box, but uses display: flex CSS rule. We'll use Flex as the basis for our Podcast component.
  • Heading - Used for rendering headlines, we'll use Heading to display the podcast title.
  • Text - For rendering text and paragraphs, we'll use Text to show the podcast description.
  • Tag - For labels or keywords, we'll use Tags to display the podcast categories.
  • Stack - Used to stack elements together with even spacing, we'll use an inline Stack to group Tag components describing the podcast categories.

Here's the initial structure of the Podcast component, displaying the basic podcast detail information that comes back from the podcastSearch GraphQL query.

const Podcast = ({ podcast }) => {
  const { title, itunesId, description, artwork, categories } = podcast;

  return (
    <Flex rounded="lg" borderWidth="2px" m={4} style={{ maxWidth: '700px' }}>
      <Box width="200px">
        <Image src={artwork} boxSize="200px" />
        <Button width="100%">
          <AddIcon />
        </Button>
      </Box>

      <Box m={4} maxWidth="300px">
        <Heading noOfLines={2}>{title}</Heading>
        <Text noOfLines={3}>{description}</Text>

        <Stack isInline>
          {categories.slice(0, 3).map((c) => {
            return <Tag>{c}</Tag>;
          })}
        </Stack>
      </Box>
    </Flex>
  );
};

export default Podcast;

Now's it's time to add the functionality for subscribing the user to the podcast when they click the subscribe button.

First, we define the GraphQL query we want to execute, including declaring the podcast's iTunes ID as a GraphQL variable.

const PODCAST_SUBSCRIBE = gql`
  mutation podcastSubscribe($itunesID: String!) {
    subscribeToPodcast(itunesId: $itunesID) {
      title
      itunesId
    }
  }
`;

Now we use Apollo Client's useMutation hook to execute the mutation when the user clicks the button by triggering the mutation function in the button's onClick handler.

components/Podcast.js
import {
  Button,
  Flex,
  Box,
  Image,
  Heading,
  Text,
  Stack,
  Tag
} from '@chakra-ui/react';
import { AddIcon } from '@chakra-ui/icons';
import { gql, useMutation } from '@apollo/client';

const PODCAST_SUBSCRIBE = gql`
  mutation podcastSubscribe($itunesID: String!) {
    subscribeToPodcast(itunesId: $itunesID) {
      title
      itunesId
    }
  }
`;

const Podcast = ({ podcast }) => {
  const { title, itunesId, description, artwork, categories } = podcast;
  const [subscribeMutation, { data }] = useMutation(PODCAST_SUBSCRIBE);

  return (
    <Flex rounded="lg" borderWidth="2px" m={4} style={{ maxWidth: '700px' }}>
      <Box width="200px">
        <Image src={artwork} boxSize="200px" />
        <Button
          width="100%"
          onClick={() =>
            subscribeMutation({ variables: { itunesID: itunesId } })
          }
        >
          <AddIcon />
        </Button>
      </Box>

      <Box m={4} maxWidth="300px">
        <Heading noOfLines={2}>{title}</Heading>
        <Text noOfLines={3}>{description}</Text>

        <Stack isInline>
          {categories.slice(0, 3).map((c) => {
            return <Tag>{c}</Tag>;
          })}
        </Stack>
      </Box>
    </Flex>
  );
};

export default Podcast;

Adding The /podcasts Page#

Again, we'll make use of Next.js's file based router by creating pages/podcasts.js to create a route at /podcasts in our application.

This page will be have a text input for the user to input their search terms and button to initiate the search. Because the user is taking an action (clicking the search button) to explicitly begin the search and execute the GraphQL podcastSearch query - rather than running the GraphQL query when the component loads - we'll use Apollo Client's useLazyQuery to manually execute the query.

pages/podcasts.js
import { useState } from 'react';
import { useLazyQuery, gql } from '@apollo/client';
import { useAuth } from '../lib/auth';
import SignIn from '../components/SignIn';
import {
  FormLabel,
  FormControl,
  Input,
  Button,
  Container,
  Flex,
  SimpleGrid,
  VStack
} from '@chakra-ui/react';
import Podcast from '../components/Podcast';

const PodcastSearchQuery = gql`
  query podcastSearch($searchTerm: String!) {
    podcastSearch(searchTerm: $searchTerm) {
      itunesId
      title
      description
      feedURL
      artwork
      categories
    }
  }
`;

const Podcasts = () => {
  const [getPodcasts, { data }] = useLazyQuery(PodcastSearchQuery);
  const { isSignedIn } = useAuth();
  const [searchString, setSearchString] = useState('');
  return (
    <Container>
      {!isSignedIn() && <SignIn />}
      {isSignedIn() && (
        <div>
          <FormControl id="podcastsearch">
            <FormLabel>Search podcasts</FormLabel>
            <Flex>
              <Input onChange={(e) => setSearchString(e.target.value)} />
              <Button
                ml={4}
                onClick={() =>
                  getPodcasts({ variables: { searchTerm: searchString } })
                }
              >
                Search
              </Button>
            </Flex>
          </FormControl>
          <div>
            <VStack>
              {data &&
                data.podcastSearch.map((p) => {
                  return <Podcast podcast={p} />;
                })}
            </VStack>
          </div>
        </div>
      )}
    </Container>
  );
};

export default Podcasts;

With that we now some basic working functionality in our GRANDcast.FM podcast app. There are still a few things we need to address in upcoming streams:

  • Move our GraphQL API into a Next.js API endpoint
  • Deploy the application, including provisioning a Neo4j instance in the cloud
  • Migrating to the new Neo4j GraphQL Library

What else should we cover on the livestreams? Let me know on Twitter if you have any ideas for things you'd so see us explore on the livestreams.

Resources#

Subscribe To Will's Newsletter

Want to know when the next blog post or video is published? Subscribe now!