DEV Community

loading...
Cover image for How to build a restaurant listing UI for Strapi using GC & GraphQL

How to build a restaurant listing UI for Strapi using GC & GraphQL

chrisczopp profile image chris-czopp ・11 min read

Intro

This article is dedicated for a web developer who appreciates design freedom, yet who'd like to code less in a setup-free web-based development environment.

It's a "how to" integrate with Strapi using GlueCodes Studio - the tool powering your every-day work in the ways you haven't seen elsewhere. It's for somebody who'd be pleased with loads of automation to deliver an extremely fast and scalable code i.e. build-time diffed JSX using SolidJS and organised around an implicit uni-directional data flow. Obviously you can use it for FREE. Without further "context drawing", let's begin.

What are we building?

We're going to use Strapi as a headless CMS. It comes with a hosted demo for an imaginary Food Advisor site and it's already seeded with restaurant data. You can request your instance here. After filling in a form, you'll receive an email with few URLs. Mine looked like these:

Demo URL: https://api-hi2zm.strapidemo.com/admin

API restaurants URL: https://api-hi2zm.strapidemo.com/restaurants

GraphQL URL: https://api-hi2zm.strapidemo.com/graphql

Credentials: john@doe.com / welcomeToStrapi123
Enter fullscreen mode Exit fullscreen mode

Don't try to be a smartass, the URLs won't work longer that the demo duration you provided in the demo form.

I won't be covering how to use Strapi, just explore it yourself if you like. For our tutorial all you'll need is these two URLs:

GraphQL:
https://api-{someHash}.strapidemo.com/graphql

Image Server:
https://api-{someHash}.strapidemo.com
Enter fullscreen mode Exit fullscreen mode

Our app will have the following features:

  • grid of restaurants with names, description, category and image
  • filtering by category
  • filtering by neighborhood
  • filtering by language
  • pagination

The app will apply the filters without the browser hard-reload, meaning it'll be SPA. In Part 1, we will focus on the Strapi integration and leave pagination and mobile responsiveness for Part 2. I'll leave any styling improvements to you as it isn't a CSS tutorial. It'll look like this:

Alt Text

Coding

First, you'll need go to: GlueCodes Studio. You'll be asked to sign up via Google or Github. No worries, it won't require any of your details. Once you're in the project manager, choose "Strapi Food Advisor" template. You'll be asked to choose a directory where the project suppose to be stored. Just choose one and you should be redirected to IDE.

Alt Text

You might be welcomed with some introjs walk-through(s) guiding you around something like this:

Alt Text

As mentioned above, you'll need two URLs:

GraphQL:
https://api-{someHash}.strapidemo.com/graphql

Image Server:
https://api-{someHash}.strapidemo.com
Enter fullscreen mode Exit fullscreen mode

Let's add them to Global Variables as GQL_URL and IMAGE_BASE_URL:

Alt Text

Now you can click "Preview" to see the working app.

App data flow design

We'll need a list of restaurants pulled from Strapi's GraphQL API. GlueCodes Studio has a built-in data flow management. Your business logic is spread across app actions which store their returned/resolved values in a single object store. The data changes flow in one direction and UI reacts to changes of the store, updating the only affected parts. The DOM diffing happens in-compilation time and is powered by SolidJS.

There are two types of actions; the ones that supply data before rendering called providers and those triggered by a user called commands. Their both returned/resolved values are accessible from a single object store by their own names. In your UI, you get access to global variables: actions and actionResults. The variable actions is an object of Commands you can call to perform an action e.g. to return/resolve fetched data. You can read more in docs. It's really easier done than said so bear with me.

The API call we're going use returns restaurants along with categories. Our app also needs a list of neighborhoods and parse URL query parameters to affect the GraphQL call. We'll also need some basic data transformations before passing it to our UI. Based on this information, I decided to have the following providers:

  • fetchRestaurantData
  • getCategories
  • getLanguages
  • getNeighborhoods
  • getRestaurants
  • parseUrlQueryParams

For filtering, we'll need the following commands:

  • changeCategory
  • changeLanguage
  • changeNeighborhood

I'll walk you through them one by one but before, you need to understand the mechanism of providers a bit further. Note that providers, when returning they implicitly write to a single object store by their own names. Then, a snapshot of this store is passed from one provider to another. It means you can access results of the previously called providers. It also means you need to set their execution order. It's done by navigating to a particular provider and clicking "Run After" button and in its corresponding pane, choose which providers need to be executed before. You can expect something like this:

Alt Text

We want to achieve the following pipeline:

The fetchRestaurantData uses a result of parseUrlQueryParams.

The getRestaurants and getCategories use a result of fetchRestaurantData.

It can look like this:

  1. getNeighborhoods
  2. parseUrlQueryParams
  3. fetchRestaurantData
  4. getRestaurants
  5. getLanguages
  6. getCategories

OK, let's dive into functions now.

Actions

providers/fetchRestaurantData:

export default async (actionResults) => {
  const { category, district, locale } = actionResults.parseUrlQueryParams 

  const where = {
    locale: 'en'
  }

  if (category !== 'all') {
    where.category = category
  }

  if (district !== 'all') {
    where.district = district
  }

  if (locale) {
    where.locale = locale
  }

  const query = `
    query ($limit: Int, $start: Int, $sort: String, $locale: String, $where: JSON) {
      restaurants(limit: $limit, start: $start, sort: $sort, locale: $locale, where: $where) {
        id
        description
        district
        cover {
          url
        }
        category {
          name
        }
        name
        locale
        localizations {
          id
          locale
        }
        note
        price
        reviews {
          note
          content
        }
      }
      restaurantsConnection(where: $where) {
        aggregate {
          count
        }
      }
      categories {
        id
        name
      }
    }
  `

  const records = await (await fetch(global.GQL_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      query,
      variables: {
        limit: 15,
        start: actionResults.parseUrlQueryParams.start || 0,
        sort: 'name:ASC',
        locale: 'en',
        where
      }
    })
  })).json()

  return records.data
}

Enter fullscreen mode Exit fullscreen mode

Notes:

  • actionResults.parseUrlQueryParams accesses the query URL params
  • global.GQL_URL accesses the GQL_URL global variable

providers/getCategories:

export default (actionResults) => {
  return [
    {
      id: 'all',
      name: 'All'
    },
    ...actionResults.fetchRestaurantData.categories  
  ]
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • actionResults.fetchRestaurantData.categories accesses the categories which are part of fetchRestaurantData result

providers/getLanguages:

export default () => {
  return [
    {
      id: 'en',
      name: 'En'
    },
    {
      id: 'fr',
      name: 'Fr'
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

providers/getNeighborhoods:

export default () => {
  return [
    { name: 'All', id: 'all' },
    { name: '1st', id: '_1st' },
    { name: '2nd', id: '_2nd' },
    { name: '3rd', id: '_3rd' },
    { name: '4th', id: '_4th' },
    { name: '5th', id: '_5th' },
    { name: '6th', id: '_6th' },
    { name: '7th', id: '_7th' },
    { name: '8th', id: '_8th' },
    { name: '9th', id: '_9th' },
    { name: '10th', id: '_10th' },
    { name: '11th', id: '_11th' },
    { name: '12th', id: '_12th' },
    { name: '13th', id: '_13th' },
    { name: '14th', id: '_14th' },
    { name: '15th', id: '_15th' },
    { name: '16th', id: '_16th' },
    { name: '17th', id: '_17th' },
    { name: '18th', id: '_18th' },
    { name: '19th', id: '_19th' },
    { name: '20th', id: '_20th' }
  ]
}
Enter fullscreen mode Exit fullscreen mode

providers/getRestaurants:

export default (actionResults) => {
  return actionResults.fetchRestaurantData.restaurants
    .map((record) => ({
      id: record.id,
      name: record.name,
      description: record.description,
      category: record.category.name,
      district: record.district,
      thumbnail: record.cover[0].url
    }))
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • actionResults.fetchRestaurantData.restaurants accesses the restaurants which are part of fetchRestaurantData result

providers/parseUrlQueryParams:

export default (actionResults) => {
  return imports.parseUrlQueryParams()
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • imports.parseUrlQueryParams accesses an external dependency function.

In GlueCodes Studio you can use any UMD-bundled modules including those in UNPKG. Just click on Dependencies icon and edit the JSON file to look like:

{
  "css": {
    "bootstrap": "https://unpkg.com/bootstrap@4.5.2/dist/css/bootstrap.min.css",
    "fa": "https://unpkg.com/@fortawesome/fontawesome-free@5.14.0/css/all.min.css"
  },
  "js": {
    "modules": {
      "parseUrlQueryParams": "https://ide.glue.codes/repos/df67f7a82cbdc5efffcb31c519a48bf6/basic/reusable-parseUrlQueryParams-1.0.4/index.js",
      "setUrlQueryParam": "https://ide.glue.codes/repos/df67f7a82cbdc5efffcb31c519a48bf6/basic/reusable-setUrlQueryParam-1.0.4/index.js"
    },
    "imports": {
      "parseUrlQueryParams": {
        "source": "parseUrlQueryParams",
        "importedName": "default"
      },
      "setUrlQueryParam": {
        "source": "setUrlQueryParam",
        "importedName": "default"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

commands/changeCategory:

export default (categoryId) => {
  imports.setUrlQueryParam({ name: 'category', value: categoryId })
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • imports.setUrlQueryParam accesses an external dependency function

commands/changeLanguage:

export default (languageId) => {
  imports.setUrlQueryParam({ name: 'locale', value: languageId })
}
Enter fullscreen mode Exit fullscreen mode

commands/changeNeighborhood:

export default (neighborhoodId) => {
  imports.setUrlQueryParam({ name: 'district', value: neighborhoodId })
}
Enter fullscreen mode Exit fullscreen mode

Structure

In GlueCodes Studio each page is split into logical UI pieces to help you keep your UI modular. A single slot has its scoped CSS which means it can be styled by classes which only affect a given slot and their names can be duplicated in other slots. In the exported code, slots will be extracted to dedicated files making them more maintainable.

To make your HTML dynamic, you can use attribute directives as you would in modern web frameworks. When typing most of them, you'll get notified to auto-create (if don't exist) required commands, providers or to install a widget. The vocabulary is quite simple, attribute [gc-as] tells what it is and other [gc-*] attributes are parameters. Note: For any naming attributes use camelcase e.g. for a slot you would use [gc-name="myAwesomeSlot"].

Here is a slightly stripped-out index page HTML:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta gc-as="navName" gc-name="Home">
  <title>FoodAdvisor</title>
<body>
  <div gc-as="layout">
    <div class="container-fluid">
      <div gc-as="slot" gc-name="header"></div>
      <div class="d-flex">
        <div gc-as="slot" gc-name="filters"></div>
        <div gc-as="slot" gc-name="content">
          <div class="contentWrapper">
            <h1 class="heading">Best restaurants in Paris</h1>
            <div class="grid">
              <div gc-as="listItemPresenter" gc-provider="getRestaurants" class="card">
                <img-x class="card-img-top thumbnail" alt="Card image cap">
                  <script>
                    props.src = `${global.IMAGE_BASE_URL}${getRestaurantsItem.thumbnail}`
                  </script>
                </img-x>
                <div class="card-body">
                  <h4 gc-as="listFieldPresenter" gc-provider="getRestaurants" gc-field="name" class="name">restaurant name</h4>
                  <h5 gc-as="listFieldPresenter" gc-provider="getRestaurants" gc-field="category" class="category">restaurant category</h5>
                  <p gc-as="listFieldPresenter" gc-provider="getRestaurants" gc-field="description" class="card-text">restuarant description</p>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div gc-as="slot" gc-name="footer"></div>
    </div>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Notes:

  • <div gc-as="layout"> is the app wrapper.
  • <div gc-as="slot" gc-name="content"> is a logical UI piece which has its scoped CSS and is extracted to dedicated file. It requires a unique (within page) camelcase gc-name. Whatever is in slot gets access to a store, commands and other useful variables. You can learn more here.
  • <div gc-as="slot" gc-name="filters"></div> is a reusable slot. Similar to a slot however it can be used across multiple pages. Reusable slots can be understood as partials. You'll be editing reusable slots in a dedicated HTML editor and injecting them in pages using empty slot directive.
  • <div gc-as="listItemPresenter" gc-provider="getRestaurants" class="card"> repeats this div over an array returned by getRestaurants provider.
  • <h4 gc-as="listFieldPresenter" gc-provider="getRestaurants" gc-field="name" class="name">restaurant name</h4> displays a property name of an item while looping over getRestaurants provider.

Let's take a look at this once more:

<img-x class="card-img-top thumbnail" alt="Card image cap">
  <script>
    props.src = `${global.IMAGE_BASE_URL}${getRestaurantsItem.thumbnail}`
  </script>
</img-x>
Enter fullscreen mode Exit fullscreen mode

Static HTML has no built-in way to make it reactive. Hence GlueCodes Studio has a concept called extended tags which is named like: tagName + '-x' and has an embedded <script> included. Its code is sandboxed allowing you to access variables which are available inside other directive like slots or list item presenters. The scripts can assign to props variable to change props/attributes of the extended tag.

Note that when an extended tag is placed inside a list item presenter you get access to a variable called like: providerName + Item, in our case getRestaurantsItem which is an item while looping over getRestaurants provider. You could also access getRestaurantsIndex for a numeric index in the array.

Other Templates:

reusableSlots/filters:

<div class="wrapper">
  <h2 class="heading">Categories</h2>
  <ul class="filterSet">
    <li gc-as="listItemPresenter" gc-provider="getCategories" class="filterItem">
      <label>
        <input-x type="radio">
          <script>
            props.name = 'category'
            props.value = getCategoriesItem.id
            props.checked = getCategoriesItem.id === (actionResults.parseUrlQueryParams.category || 'all')
            props.onChange = (e) => {
              actions.changeCategory(e.target.value)
              actions.reload()
            }
          </script>
        </input-x>
        <span gc-as="listFieldPresenter" gc-provider="getCategories" gc-field="name" class="label">category name</span>
      </label>
    </li>
  </ul>
  <h2 class="heading">Neighborhood</h2>
  <ul class="filterSet">
    <li gc-as="listItemPresenter" gc-provider="getNeighborhoods" class="filterItem">
      <label>
        <input-x type="radio">
          <script>
            props.name = 'neighborhood'
            props.value = getNeighborhoodsItem.id
            props.checked = getNeighborhoodsItem.id === (actionResults.parseUrlQueryParams.district || 'all')
            props.onChange = (e) => {
              actions.changeNeighborhood(e.target.value)
              actions.reload()
            }
          </script>
        </input-x>
        <span gc-as="listFieldPresenter" gc-provider="getNeighborhoods" gc-field="name" class="label">neighborhood name</span>
      </label>
    </li>
  </ul>
  <h2 class="heading">Language</h2>
  <ul class="filterSet">
    <li gc-as="listItemPresenter" gc-provider="getLanguages" class="filterItem">
      <label>
        <input-x type="radio">
          <script>
            props.name = 'languages'
            props.value = getLanguagesItem.id
            props.checked = getLanguagesItem.id === (actionResults.parseUrlQueryParams.locale || 'en')
            props.onChange = (e) => {
              actions.changeLanguage(e.target.value)
              actions.reload()
            }
          </script>
        </input-x>
        <span gc-as="listFieldPresenter" gc-provider="getLanguages" gc-field="name" class="label">language name</span>
      </label>
    </li>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

reusableSlots/footer:

<footer class="wrapper">
  <p>Try <a href="https://www.glue.codes" class="link">GlueCodes Studio</a> now!</p>
  <ul class="nav">
    <li class="navItem">
      <a href="https://www.facebook.com/groups/gluecodesstudio" class="navLink"><i class="fab fa-facebook"></i></a>
    </li>
    <li class="navItem">
      <a href="https://www.youtube.com/channel/UCDtO8rCRAYyzM6pRXy39__A/featured?view_as=subscriber" class="navLink"><i class="fab fa-youtube"></i></a>
    </li>
    <li class="navItem">
      <a href="https://www.linkedin.com/company/gluecodes" class="navLink"><i class="fab fa-linkedin-in"></i></a>
    </li>
  </ul>
</footer>
Enter fullscreen mode Exit fullscreen mode

reusableSlots/header:

<nav class="navbar navbar-light bg-light wrapper">
  <a class="navbar-brand link" href="/">
    <img-x width="30" height="30" alt="FoodAdvisor" class="logo">
      <script>
        props.src = mediaFiles['logo.png'].src
      </script>
    </img-x> FoodAdvisor
  </a>
</nav>
Enter fullscreen mode Exit fullscreen mode

You can access any images or videos you drop in the studio via mediaFiles variable which is an object where file names are the keys. Implicitly there is a Webpack Responsive Loader involved which gives you src and placeholder.

Styles

For styling, although it feels like coding oldschool HTML and CSS, you'll be implicitly using CSS Modules. GlueCodes Studio gives you a beautiful balance between scoped and global styling. So, you can theme your app globally and at the same time style chosen parts of the UI in isolation. You'll simply be using CSS classes and because of the implicit scoping you can safely duplicate class names among different slots.

Alt Text

Notice a rather unusual @import statements. It's a way of importing third-party CSS from dependencies or global styles. The names must match the ones in Dependencies JSON or name of a global stylesheet.

pages/index/This Page CSS

@import 'bootstrap';
Enter fullscreen mode Exit fullscreen mode

pages/index/Content Slot CSS

@import 'bootstrap';
@import 'fa';
@import 'theme';

.contentWrapper {
  padding: 0 20px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: 30px;
  margin-top: 40px;
}

.heading {
  margin-bottom: 0;
  font-size: 32px;
}

.thumbnail {
  transition: transform 0.3s;
}

.thumbnail:hover {
  transform: translateY(-4px); 
}

.name {
  font-weight: 700;
  font-size: 16px;
  color: rgb(25, 25, 25);
}

.category {
  font-size: 13px;
  color: #666;
}
Enter fullscreen mode Exit fullscreen mode

reusableSlots/filters:

.wrapper {
  padding: 0 20px;
  padding-top: 75px;
  min-width: 250px;
}

.filterSet, .filterItem {
  margin: 0;
  padding: 0;
}

.filterSet {
  margin-bottom: 30px;
}

.filterItem {
  list-style: none;
}

.filterItem label {
  cursor: pointer;
}

.label {
  padding-left: 4px;
}

.heading {
  padding-bottom: 15px;
  font-weight: 700;
  font-size: 16px;
  color: rgb(25, 25, 25);
}
Enter fullscreen mode Exit fullscreen mode

reusableSlots/footer:

@import 'fa';

.wrapper {
  margin-top: 70px;
  padding: 20px;
  background-color: #1C2023;
  color: white;
}

.link {
  color: white;
}

.link:hover {
  color: #219F4D;
  text-decoration: none;
}

.nav {
  display: flex;
  margin: 0;
  padding: 0;
}

.navItem {
  list-style: none;  
}

.navLink {
  display: inline-block;
  margin-right: 2px;
  width: 40px;
  height: 40px;
  line-height: 40px;
  text-align: center;
  font-size: 18px;
  border-radius: 50%;
  background-color: #272a2e;
}

.navLink,
.navLink:hover,
.navLink:active,
.navLink.visited {
  text-decoration: none;
  color: white;
}

.navLink:hover {
  background-color: #219F4D;
}
Enter fullscreen mode Exit fullscreen mode

reusableSlots/header:

.wrapper {
  padding: 20px;
  background: #1C2023;
  margin-bottom: 30px;
}

.link {
  color: white;
  font-size: 18px;
  font-weight: 700;
}

.link,
.link:hover,
.link:active,
.link:visited {
  color: white;
  text-decoration: none;
}

.logo {
  margin-right: 3px;
}
Enter fullscreen mode Exit fullscreen mode

What's next?

As you may have noticed there is a tone of details which hopefully is reasonably absorbable. I'll share a direct link to the project soon after releasing this article. Enjoy building your custom CMSs with GlueCodes Studio and Strapi.

Let me know whether I should write Part 2 or if there is some other integration you'd love to see.

Also, join our Facebook Forum

Discussion (0)

Forem Open with the Forem app