DEV Community

loading...
Cover image for Streamline your Vue 3 development with Fauna, Typescript, and GraphQL

Streamline your Vue 3 development with Fauna, Typescript, and GraphQL

Justin Boyson
Super pumped to be a Senior Frontend Engineer at GitLab! Into all things javascripty with a heavy focus on Vue.
・14 min read

Written in connection with the Write with Fauna Program.

Photo by Srinivasan Venkataraman on Unsplash

GraphQL is amazing, but writing reducers and setting up the server side can be a bit daunting. Thankfully Fauna can do the heavy lifting and let us focus on the fun stuff!

To take it up a notch let’s sync up our GraphQL types with a code generator so that we can leverage Typescript and and VSCode’s intellisense for a seamless coding experience.

To demonstrate we will be building a simple orders list.

order-list

Overview

  1. Set up a vite app
  2. Build a static version of order list
  3. Build our schema and upload to Fauna
  4. Use codegen to create types for our schema
  5. Wire up the static list to live data
  6. Bonus: Updating schema workflow

We’ll be using yarn, and vite for this project but you could just as easily use Vue CLI if you prefer and npm. Also if react is more your cup of coffee, most of this will still be applicable.

Set up a vite app

Setting up vite is delightfully easy. Simply run yarn create @vitejs/app in your terminal and follow the prompts. I called mine “fauna-order-list” and chose the “vue-ts” template. Then cd into your directory and run yarn

That’s it!

Go ahead and run your app with yarn dev

Behold the mighty Vue starter template!

vue-template

Build a static version of order list

For quick styling we’ll be using tailwind. The tailwind documentation is great so go here and follow their instructions.

Note: if you have the dev server running still you will need to restart the process to see tailwind’s styles.

Next create a new file src/components/OrderList.vue and paste in the following code:

<template>
  <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
    <table class="min-w-full divide-y divide-gray-200">
      <thead class="bg-gray-50">
        <tr>
          <th scope="col" class="order-header">Name</th>
          <th scope="col" class="order-header">Address</th>
          <th scope="col" class="order-header">Order</th>
          <th scope="col" class="order-header">Total</th>
        </tr>
      </thead>
      <tbody>
        <tr class="bg-white">
          <td class="order-cell">
            <span class="bold">Jane Cooper</span>
          </td>
          <td class="order-cell">123 Main St, New York, NY 12345</td>
          <td class="order-cell">12oz Honduras x 1</td>
          <td class="order-cell">$12.00</td>
        </tr>
        <tr class="bg-gray-50">
          <td class="order-cell">
            <span class="bold">Bob Smith</span>
          </td>
          <td class="order-cell">456 Avenue G, New York, NY 12345</td>
          <td class="order-cell">12oz Honduras x 1, 12oz Ethiopia x 2</td>
          <td class="order-cell">$36.00</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<style>
.order-cell {
  @apply px-6 py-4 whitespace-nowrap text-sm text-gray-500;
}

.order-header {
  @apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}

.bold {
  @apply font-medium text-gray-900;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Now we need to add the OrderList component to our app.

Open up src/App.vue and replace all occurrences of HelloWorld with OrderList. While we’re in here let’s delete the style section and image as well. I’ve also wrapped OrderList in a section and added a couple styles to center and pad the list.

Your completed App.vue file should look like this.

<template>
  <section class="container mx-auto mt-8">
    <OrderList />
  </section>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import OrderList from "./components/OrderList.vue";

export default defineComponent({
  name: "App",
  components: {
    OrderList,
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

Build our schema and upload to Fauna

A basic schema

For our example data we will have two primary data objects. Customers and Orders.

Customers will have a name and address. Orders will have an array of LineItems and a reference to a Customer

Our basic schema looks like this:

type Address {
  line1: String!
  line2: String
  city: String!
  state: String!
  postal_code: String!
}

type Customer {
  name: String!
  address: Address
}

type LineItem {
  name: String!
  amount: Int!
  quantity: Int!
}

type Order {
  items: [LineItem!]!
  customer: Customer!
}
Enter fullscreen mode Exit fullscreen mode

Here we can see an Address type that could be used to store information like you might gather from a checkout form. Likewise, we have a LineItem type to hold individual items for a given order.

Fauna specific directives

Fauna comes with several directives to help auto generate resolvers for your schema. They’re powerful and well worth your time to read through the documentation. In this tutorial we will be covering two of them: @collection and @embedded.

In Fauna, all types in a graphql schema are considered @collection by default. If you are coming from a relational database background you can think of collections as being similar to tables.

I prefer to explicitly name my collections using the @collection directive to remove any ambiguity, and because I am finicky about my document names 😁

The @collection directive takes one simple argument: “name” which is what Fauna will call the document for this collection.

Looking at our current schema we only want customers and orders collections. To prevent Address and LineItem from becoming documents we use the @embedded directive. This simply tells Fauna to not create separate collections for these types. These types are “embedded” directly in whatever other type references them.

Our final schema should now look like this:

type Address @embedded {
  line1: String!
  line2: String
  city: String!
  state: String!
  postal_code: String!
}

type Customer @collection(name: "customers") {
  name: String!
  address: Address
  orders: [Order!] @relation
}

type LineItem @embedded {
  name: String!
  total: Int!
  quantity: Int!
}

type Order @collection(name: "orders") {
  items: [LineItem!]!
  customer: Customer!
}
Enter fullscreen mode Exit fullscreen mode

Save this to src/graphql/schema.graphql

Upload to Fauna

Navigate to https://dashboard.fauna.com and create a new database. I called my “fauna-order-list”, the same as my vite app. Then select “GraphQL'' from the left sidebar.

Click the import button and select schema.graphql

After a few moments you should have the GraphQL Playground open.

Add some data

The GraphQL Playground is actually pretty handy for creating test data. We won’t cover updating information from the app in this tutorial, but generally when writing queries I write and execute them in the GraphQL Playground first. Then copy and paste those queries as string constants locally. This way I know that my queries are correct.

Create a customer

Since our orders need a customer to reference, let’s start by creating a customer.

Fauna has already generated create mutations for both orders and customers. You can see this in the very helpful auto-completions in the playground. If you have never used a GraphQL playground before you can see what is available at any time by opening up the “DOCS” tab on the right hand side of the window.

Your query should look like this at this point:

mutation {
  createCustomer
}
Enter fullscreen mode Exit fullscreen mode

Note how there is a red indicator to the left of line 2. If you hover over “createCustomer” you will get a helpful popover with error information. In this case it is asking for subfields to return, and saying that a “CustomerInput!” is required.

For our purposes we just need the "_id". And for the CustomerInput we’re going to use a variable, so we add a variable reference as well.

Our final mutation looks like this, and our error messages have all been resolved:

mutation ($data: CustomerInput!) {
  createCustomer(data: $data) {
    _id
  }
}
Enter fullscreen mode Exit fullscreen mode

Now to provide a value for $data at the bottom of the tab you will find “QUERY VARIABLES” and “HTTP HEADERS”

Click “QUERY VARIABLES”. You can also drag the variables tab up to create more room.

Inside the variables tab we have the same auto-complete functionality as above. You can ctrl+space to see what options are available, or just start typing if you already know what you need.

Here you will see. that since createCustomer needs a data parameter, that is what auto-complete will recommend. Also notice as you add properties, the error hints that show up. These will all guide you toward providing correctly formatted data as per your schema.

Once you have your variable filled out, click the play button to see your result. Since we only asked for _id to be returned that is what you should see in the result tab.

Here is my final variable for reference:

{
  "data": {
    "name": "John Smith",
    "address": {
      "line1": "123 Main St",
      "city": "Austin",
      "state": "TX",
      "postal_code": "12345"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Create an order

Since orders need a customer reference we’re going to leave this tab open and start a new tab for the orders. Click the + tab right next to the createCustomer tab. As a quick aside, notice how the tabs are helpfully named after the query that is being run and that the type of query is labeled as well. In this case, “M” for mutation.

The createOrder mutation follows the same process as before. The interesting part here is how we connect customers to orders. Take a look at the final variable here:

{
  "data": {
    "customer": {
      "connect": "294049263884698114"
    },
    "items": [
      {
        "name": "12oz Honduras",
        "total": 1200,
        "quantity": 1
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

That connect parameter is what “links” an order to a customer. In the auto-complete when writing this variable you may have noticed another option, create.

This is one of the really cool and powerful things about GraphQL. This kind of flexibility allows us to create a customer and an order at the same time, while simultaneously linking them together. And Fauna took care of all the resolvers for us, so we get all of this functionality for free! 😍

Here is an example variable parameter to use with createOrder that does all of the above:

{
  "data": {
    "items": [
      {
        "name": "12oz Ethiopia",
        "quantity": 1,
        "total": 1200
      }
    ],
    "customer": {
      "create": {
        "name": "Jane Doe",
        "address": {
          "line1": "456 Ave A",
          "city": "New York",
          "state": "NY",
          "postal_code": "11111"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Listing the orders

Now that we have some test data let’s open another GraphQL Playground tab and write the select query. Using ctrl+space you should notice something missing. Currently it looks like we can only “findByCustomerID'' and “findByOrderID” 🤔

At the time of this writing we need to explicitly create an @index to display our Orders.

In plain GraphQL we are simply defining a Query. Since we want to return “all orders” let’s call this one allOrders. This will be an array of Orders and we want to insist that it return something so our basic GraphQL query looks like this:

type Query {
  allOrders: [Order!]!
}
Enter fullscreen mode Exit fullscreen mode

And to define the index we simply add an @index directive with a name so our final query looks like this:

type Query {
  allOrders: [Order!]! @index(name: "all_orders")
}
Enter fullscreen mode Exit fullscreen mode

We add this to our schema.graphql file, then back in GraphQL Playground select “UPDATE SCHEMA” from the top of the screen and upload our updated schema.

After a brief moment the schema is updated and we have our allOrders query available like so:

{
  allOrders {
    data {
      _id
      customer {
        name
        address {
          line1
          line2
          city
          state
          postal_code
        }
      }
      items {
        name
        total
        quantity
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Execute this query in the playground and we see our two orders! Awesome.

Notice the nested data object. This is because there are three top level properties to the return object when querying GraphQL. data holds the data for the current result set. before and after hold cursors for paginating the results. We will not go over pagination in this tutorial, but it’s good to remember that when extracting this information later that you need to reference data since the array is not available at the top level.

Now that we have a working query let’s save it locally for use later in our app. Save this query to src/graphql/queries.ts like this:

export const allOrders = `
{
  allOrders {
    data {
      _id
      customer {
        name
        address {
          line1
          line2
          city
          state
          postal_code
        }
      }
      items {
        name
        total
        quantity
      }
    }
  }
}`;
Enter fullscreen mode Exit fullscreen mode

Use codegen to create types for our schema

Now that our data is ready to go, let's set up codegen to generate types for us to use in our app.

For detailed instructions follow the documentation here. The quick version is listed below.

Execute the following commands:

yarn add graphql
yarn add -D @graphql-codegen/cli @graphql-codegen/typescript
yarn graphql-codegen init
Enter fullscreen mode Exit fullscreen mode

Here are the responses I used for the prompts:

prompts

Now if you try running yarn codegen you will get an error. 😱

We need to provide an authorization header to connect to Fauna.

Add authorization header

  1. Go to the Fauna dashboard for this database and select “Security” from the left side menu
  2. Click the “NEW KEY” button
  3. Choose “Server” for the role and save
  4. Copy the key’s secret to your clipboard
  5. Create a .env file in you app’s root directory
  6. Save the key’s secret to .env like so: VITE_FAUNA_KEY=PASTE_HERE
  7. In package.json update the codegen script by adding “-r dotenv/config” so that it looks like this: "codegen": "graphql-codegen --config codegen.yml -r dotenv/config"
  8. Open codegen.yml and add the Authorization header, and remove the “documents” key like so:

    1. overwrite: true
      schema:
        - https://graphql.fauna.com/graphql:
            headers:
              Authorization: Bearer ${VITE_FAUNA_KEY}
      generates:
        src/schema.d.ts:
          plugins:
            - "typescript"
    
    

Try running yarn codegen again. Hooray 🎉

Go take a look at schema.d.ts and see all the typing you just saved. It’s not important to understand every line that was created here. Just that these types will help ensure that we correctly format our GraphQL queries correctly, and that our data structures locally are in sync with the server.

Some notes on security and best practices

We are using Vites’ built in dotenv functionality to easily store our secret key locally, but in a real application you should never do this. The reason is that when we use the secret key later in our app Vite will actually include it in the bundled code which means anyone picking through your source could see it.

In a real application this would be stored in an environment variable on a server and you would access that api instead of hitting Fauna directly. This is beyond the scope of this tutorial, just know that you should never include secret keys in a repository, or expose them publicly.

Wire up the static list to live data

Finally we get to pull it all together.

Use Suspense to display async components

First open up App.vue and wrap OrderList in a Suspense tag to prepare for rendering components with async data

<Suspense>
  <OrderList />
</Suspense>
Enter fullscreen mode Exit fullscreen mode

Use fetch to retrieve the orders

For simplicity we’re going to just use fetch to natively retrieve the orders.

Open OrderList.vue and add a script tag using typescript and the setup option &lt;script lang="ts" setup> Note that setup is still in RFC so it may not make it into the final version of Vue 3. But it has a ton of support from the community and is most likely stable at this point. I believe it greatly reduces unnecessary boilerplate and makes for a better development experience so I’m using it in these examples.

Also note that we’ll be using Vue 3’s composition API.

Thinking through our data, the first thing we’ll need is a ref to an array to hold the orders. Then we’ll await a fetch post to Fauna and store the data in orders.value

import { ref } from "vue";
import { allOrders } from "../graphql/queries";
import type { Order } from "../schema";

const orders = ref<Order[]>([]);
orders.value = await fetch("https://graphql.fauna.com/graphql", {
  method: "POST",
  body: JSON.stringify({ query: allOrders }),
  headers: {
    Authorization: `Bearer ${import.meta.env.VITE_FAUNA_KEY}`,
  },
})
  .then((res) => res.json())
  .then(({ data }) => data.allOrders.data);
Enter fullscreen mode Exit fullscreen mode

Run the dev server yarn dev and open up the app in your browser. Using vue-devtools inspect the OrderList component and see that it now has an orders property under setup populated with our data. Neat!

Prepare the data for easier display

We’ll use a computed value to map the orders array into an easier to consume format. For example, customer.address is currently an object as you might expect it to be stored. But we just want a string to display in the table, so we will use map to create a new parsedOrders array formatted how we want it.

const parsedOrders = computed(() => {
  return orders.value.map((o) => ({
    _id: o._id,
    name: o.customer.name,
    address: `${o.customer.address?.line1} ${o.customer.address?.city}, ${o.customer.address?.state} ${o.customer.address?.postal_code}`,
    items: o.items.map((i) => `${i.name} x ${i.quantity}`).join(", "),
    total: new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
    }).format(o.items.reduce((pre, cur) => pre + cur.total, 0) / 100),
  }));
});
Enter fullscreen mode Exit fullscreen mode

Thanks to our generated schema types our IDE gives us helpful autocomplete for our deeply nested objects such as o.customer.address?.line1 and even knows to safely check that nullable fields are available.

Now we just swap out our static HTML for our parsedOrders data:

<tr
    v-for="(order, index) in parsedOrders"
    :key="order._id"
    :class="{ 'bg-gray-50': index % 2 !== 0 }"
    class="bg-white"
    >
  <td class="order-cell">
    <span class="bold">{{ order.name }}</span>
  </td>
  <td class="order-cell">{{ order.address }}</td>
  <td class="order-cell">{{ order.items }}</td>
  <td class="order-cell">{{ order.total }}</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

Our final OrderList component should look like this:

<template>
  <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
    <table class="min-w-full divide-y divide-gray-200">
      <thead class="bg-gray-50">
        <tr>
          <th scope="col" class="order-header">Name</th>
          <th scope="col" class="order-header">Address</th>
          <th scope="col" class="order-header">Order</th>
          <th scope="col" class="order-header">Total</th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="(order, index) in parsedOrders"
          :key="order._id"
          :class="{ 'bg-gray-50': index % 2 !== 0 }"
          class="bg-white"
        >
          <td class="order-cell">
            <span class="bold">{{ order.name }}</span>
          </td>
          <td class="order-cell">{{ order.address }}</td>
          <td class="order-cell">{{ order.items }}</td>
          <td class="order-cell">{{ order.total }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script lang="ts" setup>
import { ref, computed } from "vue";
import { allOrders } from "../graphql/queries";
import type { Order } from "../schema";

const orders = ref<Order[]>([]);
orders.value = await fetch("https://graphql.fauna.com/graphql", {
  method: "POST",
  body: JSON.stringify({ query: allOrders }),
  headers: {
    Authorization: `Bearer ${import.meta.env.VITE_FAUNA_KEY}`,
  },
})
  .then((res) => res.json())
  .then(({ data }) => data.allOrders.data);

const parsedOrders = computed(() => {
  return orders.value.map((o) => ({
    _id: o._id,
    name: o.customer.name,
    address: `${o.customer.address?.line1} ${o.customer.address?.city}, ${o.customer.address?.state} ${o.customer.address?.postal_code}`,
    items: o.items.map((i) => `${i.name} x ${i.quantity}`).join(", "),
    total: new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
    }).format(o.items.reduce((pre, cur) => pre + cur.total, 0) / 100),
  }));
});
</script>

<style>
.order-cell {
  @apply px-6 py-4 whitespace-nowrap text-sm text-gray-500;
}

.order-header {
  @apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}

.bold {
  @apply font-medium text-gray-900;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Bonus: Updating schema workflow

Most of our schema’s evolve over time as new requirements come in. Let’s walk through a very simple schema update and how we would update our app to accommodate.

In this example let’s say we now want to collect email addresses. First we add email to our schema.graphql

type Customer @collection(name: "customers") {
  name: String!
  address: Address
  email: String
  orders: [Order!] @relation
}
Enter fullscreen mode Exit fullscreen mode

Note: for simplicity’s sake I am leaving email as not required. If we made email required like this email: String! then we would also have to write a data migration script to update all the existing documents, because our allOrders query would now fail.

Now that our schema is updated locally, we update the schema in Fauna’s dashboard just as we did before by going to the GraphQL tab, clicking “UPDATE SCHEMA” and uploading our updated schema file.

While we’re in the dashboard let’s go ahead and update a customer to have an email address. I found this easiest to do by going to the Collections tab and editing a document directly. Pick your first customer and add an email field.

Go back to the GraphQL tab and select the allOrders tab. Add email to your query under customer and you should now see emails being returned.

Copy and paste this query back into queries.ts and now we should be ready to display emails in our app.

Run yarn codegen to sync up with Fauna

Open up OrderList.vue and add email to the parsedOrders computed variable.

Then simply display order.email next to the customer’s name. Note, you might have to refresh the page to get the schema updates to take effect.

That’s it!

Conclusion

I’ve been really enjoying using Fauna’s GraphQL endpoint. It has greatly streamlined my process for development. Considering it is still in early stages it’s only going to get better from here, which is pretty incredible.

Discussion (0)