Chapter 15: Post Production

Full Stack React Native

link Post Production

By the end of this lesson, developers will be able to:

  • Publish their first Expo application

link The Story So Far

So far, we set up a React Native application and a GraphQL server. The app is designed to allow users to sign up, login and create votes and posts.

App Setup

git clone https://github.com/Maelstroms38/movies-starter.git
cd movies-starter
yarn
yarn start

Server Setup

git clone https://github.com/Maelstroms38/movies-api
cd movies-api
yarn
yarn db:reset
yarn start

Setting Up

Let's start by opening and running both projects in two separate windows.

cd <app-name>
yarn start

cd <server-name>
yarn start

Once you have confirmed both projects are running, we will add a client to the React Native project.

Heads Up! This lesson's code edits will only affect your React Native project.

link Apollo Client

The setup for this project builds from previous React Native clients. We will be using an authentication and subscription client.

Let's add any missing dependencies to support our Websocket schema.

yarn add apollo-link-ws subscriptions-transport-ws apollo-utilities

Now we can re-create the subscriptions client, with the following changes in our App.js top level imports:

import { AppLoading } from 'expo';
import { Asset } from 'expo-asset';
import * as Font from 'expo-font';
import React, { useState } from 'react';
import {
  Platform,
  StatusBar,
  StyleSheet,
  View,
  AsyncStorage
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';

import AppNavigator from './navigation/AppNavigator';

+import { split } from 'apollo-link';
import { ApolloClient } from 'apollo-client';
import { ApolloProvider } from '@apollo/react-hooks';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
+import { WebSocketLink } from 'apollo-link-ws';
+import { getMainDefinition } from 'apollo-utilities';
import { ApolloLink, Observable } from 'apollo-link';

Below the top level imports create the following constants that will connect to our websocket server:

const HTTP_URL = 'http://';
const HTTPS_URL = 'https://';
const WS_URL = 'ws://';
const WSS_URL = 'wss://';
const DEV_URL = 'localhost:4000/graphql';
const BASE_URL = 'HEROKU-APP-NAME.herokuapp.com/graphql';

// https://www.apollographql.com/docs/react/migrating/boost-migration/
const httpLink = new HttpLink({
  uri: `${HTTP_URL}${DEV_URL}`,
  credentials: 'include'
});

// Create a WebSocket link
const wsLink = new WebSocketLink({
  uri: `${WS_URL}${DEV_URL}`,
  options: {
    lazy: true,
    reconnect: true
  }
});

// using the ability to split links, you can send data to each link
// depending on what kind of operation is being sent
const link = split(
  // split based on operation type
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink
);

Finally, include the refactored link with your ApolloClient:

const client = new ApolloClient({
-  link: ApolloLink.from([requestLink, httpLink]),
+  link: ApolloLink.from([requestLink, link]),
  cache: new InMemoryCache()
});

With that, we have an authentication and subscriptions client. Let's complete the posts integration with a refactored LinksScreen!

link LinksScreen

We will be integrating the previous PostScreen, replacing the contents of ./screens/LinksScreen.js. Insert the following snippet:

import React, { useEffect } from 'react';
import {
  StyleSheet,
  Text,
  View,
  FlatList,
  TouchableOpacity,
  ActivityIndicator
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import Post from '../components/Post';

// Queries
const POSTS_QUERY = gql`
  query {
    posts {
      id
      title
      link
      imageUrl
      author {
        id
        username
      }
    }
  }
`;

// Subscriptions
const POSTS_SUBSCRIPTION = gql`
  subscription {
    postAdded {
      id
      title
      link
      imageUrl
      author {
        id
        username
      }
    }
  }
`;

const POST_UPDATED = gql`
  subscription {
    postEdited {
      id
      title
      link
      imageUrl
      author {
        id
        username
      }
    }
  }
`;

const POST_DELETED = gql`
  subscription {
    postDeleted
  }
`;

export default function LinksScreen(props) {
  const { navigation } = props;
  const { subscribeToMore, loading, error, data } = useQuery(POSTS_QUERY, {
    variables: {}
  });

  useEffect(() => {
    subscribeToMore({
      document: POSTS_SUBSCRIPTION,
      updateQuery: (prev, { subscriptionData }) => {
        console.log(subscriptionData);
        if (!subscriptionData.data) return prev;
        const newPostItem = subscriptionData.data.postAdded;
        return Object.assign({}, prev, {
          posts: [newPostItem, ...prev.posts]
        });
      }
    });
    subscribeToMore({
      document: POST_UPDATED,
      variables: {},
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) return prev;
        const editedPost = subscriptionData.data.postEdited;
        const updatedPosts = [...prev.posts];
        const updatedIndex = updatedPosts.findIndex(
          post => post.id == editedPost.id
        );
        if (updatedIndex >= 0) {
          updatedPosts[updatedIndex] = editedPost;
        }
        return Object.assign({}, prev, {
          posts: updatedPosts
        });
      }
    });
    subscribeToMore({
      document: POST_DELETED,
      variables: {},
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) return prev;
        const deletedID = subscriptionData.data.postDeleted;
        const updatedPosts = [...prev.posts];
        const updatedIndex = updatedPosts.findIndex(
          post => post.id == deletedID
        );
        updatedPosts.splice(updatedIndex, 1);
        return Object.assign({}, prev, {
          posts: updatedPosts
        });
      }
    });
  }, []);

  if (loading) {
    return (
      <ActivityIndicator
        color="#161616"
        style={{ ...StyleSheet.absoluteFillObject }}
      />
    );
  }
  const { posts } = data;

  return (
    <View style={styles.container}>
      <FlatList
        data={posts}
        keyExtractor={(item, index) => {
          return `${index}`;
        }}
        renderItem={(post, index) => {
          const { item } = post;
          return (
            <Post
              post={item}
              onPress={() => navigation.navigate('LinkDetail', { post: item })}
            />
          );
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'space-around'
  },
  saveIcon: {
    position: 'relative',
    right: 20,
    zIndex: 8
  }
});

There are some minor revisions here, including a new LinkForm navigation.

link LinkForm

The following screen represents one of the bonus challenge solutions from a previous section. Add a new file called ./screens/LinkForm.js, with the following contents:

import React, { useState } from 'react';
import {
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
  Dimensions,
  TextInput,
  Alert
} from 'react-native';
import RoundedButton from '../components/RoundedButton';
import { Ionicons } from '@expo/vector-icons';
import { Linking } from 'expo';
import { useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';

const { width } = Dimensions.get('window');

// Mutations
const EDIT_POST = gql`
  mutation EditPost(
    $id: ID!
    $title: String!
    $link: String!
    $imageUrl: String!
  ) {
    editPost(id: $id, title: $title, link: $link, imageUrl: $imageUrl) {
      id
      title
      link
      imageUrl
    }
  }
`;

const ADD_POST = gql`
  mutation AddPost($title: String!, $link: String!, $imageUrl: String!) {
    addPost(title: $title, link: $link, imageUrl: $imageUrl)
  }
`;

export default function LinkForm({ route, navigation }) {
  const { params } = route;
  const { post } = params || {};
  const [addPost] = useMutation(ADD_POST);
  const [editPost] = useMutation(EDIT_POST);
  const [title, setTitle] = useState(post && post.title || '');
  const [link, setLink] = useState(post && post.link || '');
  const [imageUrl, setImageUrl] = useState(post && post.imageUrl || '');
  return (
    <View style={styles.container}>
      <View>
        <Text>Title</Text>
        <TextInput
          onChangeText={text => setTitle(text)}
          value={title}
          placeholder="Title"
          autoCorrect={false}
          autoCapitalize="none"
          style={styles.input}
          autoFocus
        />
      </View>
      <View>
        <Text>Link</Text>
        <TextInput
          onChangeText={text => setLink(text)}
          value={link}
          placeholder="Link"
          autoCorrect={false}
          autoCapitalize="none"
          style={styles.input}
        />
      </View>
      <View>
        <Text>Image URL</Text>
        <TextInput
          onChangeText={text => setImageUrl(text)}
          value={imageUrl}
          placeholder="Image URL"
          autoCorrect={false}
          autoCapitalize="none"
          style={styles.input}
        />
      </View>

      <RoundedButton
        text={post ? "Edit Post" : "Add Post"}
        textColor="#fff"
        backgroundColor="rgba(75, 148, 214, 1)"
        onPress={() => {
          // TextInput validation
          let nullValues = [];
          if (!title) {
            nullValues.push('Title');
          }
          if (!link) {
            nullValues.push('Link');
          }
          if (!imageUrl) {
            nullValues.push('Image URL');
          }
          if (nullValues.length) {
            Alert.alert(`Please fill in ${nullValues.join(', ')}`);
          } else {
            if (post) {
              editPost({ variables: { id: post.id, title, link, imageUrl } })
              .then(() => {
                navigation.navigate('LinkDetail', {
                  post: { id: post.id, title, link, imageUrl }
                });
              })
              .catch(err => console.log(err));
            } else {
              addPost({ variables: { title, link, imageUrl } })
              .then(() => {
                navigation.goBack();
              })
              .catch(err => console.log(err));
            }
          }
        }}
        icon={
          <Ionicons
            name="md-checkmark-circle"
            size={20}
            color={'#fff'}
            style={styles.saveIcon}
          />
        }
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'flex-start',
    alignItems: 'center',
    paddingHorizontal: 15,
    paddingVertical: 20
  },
  saveIcon: {
    position: 'relative',
    left: 20,
    zIndex: 8
  },
  input: {
    width: width - 40,
    height: 40,
    borderBottomColor: '#FFF',
    borderBottomWidth: 1
  },
  image: {
    width: width,
    height: width,
    resizeMode: 'cover'
  }
});

link LinkDetail

Now that we have re-introduced LinkForm, let's include a LinkDetail too.
Create a new file called ./screens/LinkDetail, and insert the following:

import React from 'react';
import { StyleSheet, Text, Image, ScrollView, View, Dimensions } from 'react-native';
import RoundedButton from '../components/RoundedButton';
import { useMutation } from '@apollo/react-hooks';
import { Ionicons } from '@expo/vector-icons';
import { Linking } from 'expo';
import gql from 'graphql-tag';

const { width } = Dimensions.get('window');

// Mutations
const DELETE_POST = gql`
  mutation DeletePost($id: ID!) {
    deletePost(id: $id)
  }
`;

export default function LinkDetail({ route, navigation }) {
  const [deletePost] = useMutation(DELETE_POST);
  const { params } = route;
  const { post } = params;
  const { title, link, imageUrl } = post;
  return (
    <ScrollView>
      <View style={styles.container}>
        {!!imageUrl && <Image style={styles.image} source={{ uri: imageUrl }} />}
        <Text style={styles.text}>{title}</Text>
        <RoundedButton
          text={`${link}`}
          textColor="#fff"
          backgroundColor="rgba(75, 148, 214, 1)"
          onPress={() => {
            Linking.openURL(link).catch(err => console.log(err));
          }}
          icon={
            <Ionicons
              name="md-link"
              size={20}
              color={'#fff'}
              style={styles.saveIcon}
            />
          }
        />
        <RoundedButton
          text="Edit"
          textColor="#fff"
          backgroundColor="#a9a9a9"
          onPress={() => {
            navigation.navigate('LinkForm', { post });
          }}
          icon={
            <Ionicons
              name="md-options"
              size={20}
              color={'#fff'}
              style={styles.saveIcon}
            />
          }
        />
        <RoundedButton
          text="Delete"
          textColor="#fff"
          backgroundColor="#FA8072"
          onPress={() => {
            deletePost({ variables: { id: post.id } })
              .then(() => navigation.goBack())
              .catch(err => console.log(err));
          }}
          icon={
            <Ionicons
              name="md-trash"
              size={20}
              color={'#fff'}
              style={styles.saveIcon}
            />
          }
        />
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'flex-start',
    alignItems: 'center',
    paddingHorizontal: 15
  },
  text: {
    fontSize: 32,
    color: '#161616',
    padding: 15,
    textAlign: 'center'
  },
  image: {
    width: width,
    height: width,
    resizeMode: 'cover'
  },
  saveIcon: {
    position: 'relative',
    left: 20,
    zIndex: 8
  }
});

We simply renamed PostDetail to LinkDetail and PostForm to LinkForm.

link BottomTabNavigator

Only a few final revisions left! Let's add the following changes to our tab navigator ./navigation/BottomTabNavigator.js top level imports:

import React from 'react';
import { Platform, AsyncStorage } from 'react-native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';

import TabBarIcon from '../components/TabBarIcon';
import HomeScreen from '../screens/HomeScreen';
import LinksScreen from '../screens/LinksScreen';
import SettingsScreen from '../screens/SettingsScreen';
import MovieDetail from '../screens/MovieDetail';
import LoginScreen from '../screens/LoginScreen';
import ProfileScreen from '../screens/ProfileScreen';
import AuthLoading from '../screens/AuthLoading';
+import LinkDetail from '../screens/LinkDetail';
+import LinkForm from '../screens/LinkForm';
import ProfileTab from './TopTabNavigator';

Now we can add our new LinkStack inside ./navigation/BottomTabNavigator.js:


const Stack = createStackNavigator();

function LinksStack() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="LinksScreen" component={LinksScreen} />
      <Stack.Screen name="LinkDetail" component={LinkDetail} />
      <Stack.Screen name="LinkForm" component={LinkForm} />
    </Stack.Navigator>
  )
}

const BottomTab = createBottomTabNavigator();

export default function BottomTabNavigator({ navigation, route }) {
  // Set the header title on the parent stack navigator depending on the
  // currently active tab. Learn more in the documentation:
  // https://reactnavigation.org/docs/en/screen-options-resolution.html
  navigation.setOptions({ headerShown: false });

  return (
    <BottomTab.Navigator initialRouteName={INITIAL_ROUTE_NAME}>
      <BottomTab.Screen
        name="Home"
        component={HomeStack}
        options={{
          title: 'Now Playing',
          tabBarIcon: ({ focused }) => (
            <TabBarIcon focused={focused} name="md-film" />
          )
        }}
      />
      <BottomTab.Screen
        name="Links"
        component={LinksStack}
        options={{
          title: 'Links',
          tabBarIcon: ({ focused }) => (
            <TabBarIcon focused={focused} name="md-link" />
          )
        }}
      />
      <BottomTab.Screen
        name="Profile"
        component={ProfileStack}
        options={{
          title: 'Profile',
          tabBarIcon: ({ focused }) => (
            <TabBarIcon focused={focused} name="md-person" />
          )
        }}
      />
    </BottomTab.Navigator>
  );
}

Wrapping Up

Today we built an app that supports authentication, subscriptions and full CRUD! Now would be a great time to showcase our new product. Let's publish our new app using Expo Publish.

link Expo Publish

Before getting started, its important to note that this "published" app can be made available to the following test groups:

  • Android devices with access to your build link
  • iOS devices with your Expo account credentials

Publishing your expo project yields a link like https://exp.host/@username/an-example that (almost) anyone can load your project from.

expo publish

At this point you will be prompted to sign up for Expo. You can also sign in to an existing account.

After your app is successfully published, download the "Expo Client" app to your mobile device. Using the Expo Client, you can test your application on the go and show it off!

Congratulations on completing the course!

link Final Challenge

Create React Native App

It's time to put your new skills to the test - create an app with React Native and GraphQL! We hope you enjoyed the journey and look forward to seeing your next mobile app!

format_list_bulleted
help_outline