Article original : How to Create a Full-Stack Yelp Clone with React & GraphQL (Dune World Edition)

Par Sezgi Ulucam

Je ne connaîtrai pas la peur. La peur tue l'esprit. La peur est la petite mort qui conduit à l'oblitération totale. J'affronterai ma peur. Je lui permettrai de passer sur moi et au travers de moi. Et lorsqu'elle sera passée, je tournerai mon œil intérieur vers son chemin. Là où la peur est passée, il n'y aura plus rien. Seul je resterai. - « Litanie contre la peur », Frank Herbert, Dune

Vous vous demandez peut-être : « Quel est le rapport entre la peur et une application React ? » Tout d'abord, il n'y a rien à craindre dans une application React. En fait, dans cette application particulière, nous avons banni la peur. N'est-ce pas sympathique ?

Maintenant que vous êtes prêt à être sans peur, discutons de notre application. C'est un mini clone de Yelp où, au lieu de donner leur avis sur des restaurants, les utilisateurs évaluent des planètes de la série classique de science-fiction, Dune. (Pourquoi ? Parce qu'un nouveau film Dune va sortir... mais revenons au point principal.)

Pour construire notre application Full-Stack, nous utiliserons des technologies qui nous facilitent la vie.

  1. React : Framework front-end intuitif et compositionnel, parce que nos cerveaux aiment composer les choses.
  2. GraphQL : Vous avez peut-être entendu parler des nombreuses raisons pour lesquelles GraphQL est génial. De loin, la plus importante est la productivité et le bonheur du développeur.
  3. Hasura : Configurez une API GraphQL auto-générée au-dessus d'une base de données Postgres en moins de 30 secondes.
  4. Heroku : Pour héberger notre base de données.

Et comment GraphQL m'apporte-t-il du bonheur ?

Je vois que vous êtes sceptique. Mais vous changerez probablement d'avis dès que vous aurez passé un peu de temps avec GraphiQL (le terrain de jeu GraphQL).

Utiliser GraphQL est un jeu d'enfant pour le développeur front-end, comparé aux anciennes méthodes des points de terminaison REST encombrants. GraphQL vous offre un point de terminaison unique qui écoute tous vos problèmes... je veux dire, vos requêtes. C'est un tel auditeur que vous pouvez lui dire exactement ce que vous voulez, et il vous le donnera, rien de moins et rien de plus.

Vous vous sentez enthousiasmé par cette expérience thérapeutique ? Plongeons dans le tutoriel pour que vous puissiez l'essayer dès que possible !

👇 Voici le repo si vous souhaitez coder en même temps.

Partie 1 : Recherche

Étape 1 : Déploiement sur Heroku

La première étape de tout bon voyage est de s'asseoir avec un thé chaud et de le siroter calmement. Une fois cela fait, nous pouvons déployer sur Heroku depuis le site Hasura. Cela nous permettra de configurer tout ce dont nous avons besoin : une base de données Postgres, notre moteur GraphQL Hasura et quelques collations pour le voyage.

black-books.png Pas du tout une référence à Dune

Étape 2 : Créer la table des planètes

Nos utilisateurs veulent donner leur avis sur des planètes. Nous créons donc une table Postgres via la console Hasura pour stocker les données de nos planètes. À noter, la maléfique planète Giedi Prime, qui attire l'attention avec sa cuisine non conventionnelle.

Table des planètes

Pendant ce temps, dans l'onglet GraphiQL : Hasura a auto-généré notre schéma GraphQL ! Amusez-vous avec l'Explorer ici 👇

Explorer GraphiQL

Étape 3 : Créer l'application React

Nous aurons besoin d'une UI pour notre application, nous créons donc une application React et installons quelques bibliothèques pour les requêtes GraphQL, le routage et les styles. (Assurez-vous d'avoir Node installé au préalable.)

> npx create-react-app melange
> cd melange
> npm install graphql @apollo/client react-router-dom @emotion/styled @emotion/core
> npm start

Étape 4 : Configuration d'Apollo Client

Apollo Client nous aidera pour nos requêtes réseau GraphQL et la mise en cache, afin d'éviter tout ce travail fastidieux. Nous effectuons également notre première requête et listons nos planètes ! Notre application commence à prendre forme.

import React from "react";
import { render } from "react-dom";
import { ApolloProvider } from "@apollo/client";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import Planets from "./components/Planets";

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: "[VOTRE POINT DE TERMINAISON HASURA GRAPHQL]",
  }),
});

const App = () => (
  <ApolloProvider client={client}>
    <Planets />
  </ApolloProvider>
);

render(<App />, document.getElementById("root"));

Nous testons notre requête GraphQL dans la console Hasura avant de la copier-coller dans notre code.

Image

import React from "react";
import { useQuery, gql } from "@apollo/client";

const PLANETS = gql`
  {
    planets {
      id
      name
      cuisine
    }
  }
`;

const Planets = ({ newPlanets }) => {
  const { loading, error, data } = useQuery(PLANETS);

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  return data.planets.map(({id, name, cuisine}) => (
      <div key={id}>
      <p>
          {name} | {cuisine}
      </p>
    </div>
  ));
};

export default Planets;

Étape 5 : Styliser la liste

Notre liste de planètes est bien sympathique, mais elle a besoin d'un petit relooking avec Emotion (voir le repo pour les styles complets).

Liste stylisée des planètes

Étape 6 : Formulaire de recherche et état

Nos utilisateurs veulent rechercher des planètes et les classer par nom. Nous ajoutons donc un formulaire de recherche qui interroge notre point de terminaison avec une chaîne de recherche, et transmettons les résultats à Planets pour mettre à jour notre liste de planètes. Nous utilisons également les React Hooks pour gérer l'état de notre application.

import React, { useState } from "react";
import { useLazyQuery, gql } from "@apollo/client";
import Search from "./Search";
import Planets from "./Planets";

const SEARCH = gql`
  query Search($match: String) {
    planets(order_by: { name: asc }, where: { name: { _ilike: $match } }) {
      name
      cuisine
      id
    }
  }
`;

const PlanetSearch = () => {
  const [inputVal, setInputVal] = useState("");
  const [search, { loading, error, data }] = useLazyQuery(SEARCH);

  return (
    <div>
      <Search
        inputVal={inputVal}
        onChange={(e) => setInputVal(e.target.value)}
        onSearch={() => search({ variables: { match: `%${inputVal}%` } })}
      />
      <Planets newPlanets={data ? data.planets : null} />
    </div>
  );
};

export default PlanetSearch;
import React from "react";
import { useQuery, gql } from "@apollo/client";
import { List, ListItem } from "./shared/List";
import { Badge } from "./shared/Badge";

const PLANETS = gql`
  {
    planets {
      id
      name
      cuisine
    }
  }
`;

const Planets = ({ newPlanets }) => {
  const { loading, error, data } = useQuery(PLANETS);

  const renderPlanets = (planets) => {
    return planets.map(({ id, name, cuisine }) => (
      <ListItem key={id}>
        {name} <Badge>{cuisine}</Badge>
      </ListItem>
    ));
  };

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  return <List>{renderPlanets(newPlanets || data.planets)}</List>;
};

export default Planets;
import React from "react";
import styled from "@emotion/styled";
import { Input, Button } from "./shared/Form";

const SearchForm = styled.div`
  display: flex;
  align-items: center;
  > button {
    margin-left: 1rem;
  }
`;

const Search = ({ inputVal, onChange, onSearch }) => {
  return (
    <SearchForm>
      <Input value={inputVal} onChange={onChange} />
      <Button onClick={onSearch}>Rechercher</Button>
    </SearchForm>
  );
};

export default Search;
import React from "react";
import { render } from "react-dom";
import { ApolloProvider } from "@apollo/client";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import PlanetSearch from "./components/PlanetSearch";
import Logo from "./components/shared/Logo";
import "./index.css";

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: "[VOTRE POINT DE TERMINAISON HASURA GRAPHQL]",
  }),
});

const App = () => (
  <ApolloProvider client={client}>
    <Logo />
    <PlanetSearch />
  </ApolloProvider>
);

render(<App />, document.getElementById("root"));

Étape 7 : Soyez fier

Nous avons déjà implémenté notre liste de planètes et nos fonctionnalités de recherche ! Nous contemplons avec amour notre travail, prenons quelques selfies ensemble, et passons aux avis.

Liste des planètes avec recherche

Partie 2 : Avis en direct

Étape 1 : Créer la table des avis

Nos utilisateurs visiteront ces planètes et écriront des avis sur leur expérience. Nous créons une table via la console Hasura pour nos données d'avis.

Table des avis

Nous ajoutons une clé étrangère de la colonne planet_id vers la colonne id de la table planets, pour indiquer que les planet_id des reviews doivent correspondre aux id des planets.

Clés étrangères

Étape 2 : Suivre les relations

Chaque planète a plusieurs avis, tandis que chaque avis concerne une seule planète : une relation un-à-plusieurs. Nous créons et suivons cette relation via la console Hasura, afin qu'elle puisse être exposée dans notre schéma GraphQL.

Suivi des relations

Maintenant, nous pouvons interroger les avis pour chaque planète dans l'Explorer !

Requête des avis sur les planètes

Étape 3 : Configuration du routage

Nous voulons pouvoir cliquer sur une planète et voir ses avis sur une page séparée. Nous configurons le routage avec React Router et listons les avis sur la page de la planète.

import React from "react";
import { render } from "react-dom";
import { ApolloProvider } from "@apollo/client";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import PlanetSearch from "./components/PlanetSearch";
import Planet from "./components/Planet";
import Logo from "./components/shared/Logo";
import "./index.css";

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: "[VOTRE POINT DE TERMINAISON HASURA GRAPHQL]",
  }),
});

const App = () => (
  <BrowserRouter>
    <ApolloProvider client={client}>
      <Logo />
      <Switch>
        <Route path="/planet/:id" component={Planet} />
        <Route path="/" component={PlanetSearch} />
      </Switch>
    </ApolloProvider>
  </BrowserRouter>
);

render(<App />, document.getElementById("root"));
import React from "react";
import { useQuery, gql } from "@apollo/client";
import { List, ListItem } from "./shared/List";
import { Badge } from "./shared/Badge";

const PLANET = gql`
  query Planet($id: uuid!) {
    planets_by_pk(id: $id) {
      id
      name
      cuisine
      reviews {
        id
        body
      }
    }
  }
`;

const Planet = ({
  match: {
    params: { id },
  },
}) => {
  const { loading, error, data } = useQuery(PLANET, {
    variables: { id },
  });

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  const { name, cuisine, reviews } = data.planets_by_pk;

  return (
    <div>
      <h3>
        {name} <Badge>{cuisine}</Badge>
      </h3>
      <List>
        {reviews.map((review) => (
          <ListItem key={review.id}>{review.body}</ListItem>
        ))}
      </List>
    </div>
  );
};

export default Planet;
import React from "react";
import { useQuery, gql } from "@apollo/client";
import { Link } from "react-router-dom";
import { List, ListItemWithLink } from "./shared/List";
import { Badge } from "./shared/Badge";

const PLANETS = gql`
  {
    planets {
      id
      name
      cuisine
    }
  }
`;

const Planets = ({ newPlanets }) => {
  const { loading, error, data } = useQuery(PLANETS);

  const renderPlanets = (planets) => {
    return planets.map(({ id, name, cuisine }) => (
      <ListItemWithLink key={id}>
        <Link to={`/planet/${id}`}>
          {name} <Badge>{cuisine}</Badge>
        </Link>
      </ListItemWithLink>
    ));
  };

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  return <List>{renderPlanets(newPlanets || data.planets)}</List>;
};

export default Planets;

Étape 4 : Configuration des abonnements (subscriptions)

Nous installons de nouvelles bibliothèques et configurons Apollo Client pour prendre en charge les abonnements. Ensuite, nous transformons notre requête d'avis en un abonnement afin qu'elle puisse afficher les mises à jour en direct.

> npm install @apollo/link-ws subscriptions-transport-ws
import React from "react";
import { render } from "react-dom";
import {
  ApolloProvider,
  ApolloClient,
  HttpLink,
  InMemoryCache,
  split,
} from "@apollo/client";
import { getMainDefinition } from "@apollo/client/utilities";
import { WebSocketLink } from "@apollo/link-ws";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import PlanetSearch from "./components/PlanetSearch";
import Planet from "./components/Planet";
import Logo from "./components/shared/Logo";
import "./index.css";

const GRAPHQL_ENDPOINT = "[VOTRE POINT DE TERMINAISON HASURA GRAPHQL]";

const httpLink = new HttpLink({
  uri: `https://${GRAPHQL_ENDPOINT}`,
});

const wsLink = new WebSocketLink({
  uri: `ws://${GRAPHQL_ENDPOINT}`,
  options: {
    reconnect: true,
  },
});

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLink
);

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: splitLink,
});

const App = () => (
  <BrowserRouter>
    <ApolloProvider client={client}>
      <Logo />
      <Switch>
        <Route path="/planet/:id" component={Planet} />
        <Route path="/" component={PlanetSearch} />
      </Switch>
    </ApolloProvider>
  </BrowserRouter>
);

render(<App />, document.getElementById("root"));
import React from "react";
import { useSubscription, gql } from "@apollo/client";
import { List, ListItem } from "./shared/List";
import { Badge } from "./shared/Badge";

const PLANET = gql`
  subscription Planet($id: uuid!) {
    planets_by_pk(id: $id) {
      id
      name
      cuisine
      reviews {
        id
        body
      }
    }
  }
`;

const Planet = ({
  match: {
    params: { id },
  },
}) => {
  const { loading, error, data } = useSubscription(PLANET, {
    variables: { id },
  });

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  const { name, cuisine, reviews } = data.planets_by_pk;

  return (
    <div>
      <h3>
        {name} <Badge>{cuisine}</Badge>
      </h3>
      <List>
        {reviews.map((review) => (
          <ListItem key={review.id}>{review.body}</ListItem>
        ))}
      </List>
    </div>
  );
};

export default Planet;

Page de planète avec avis en direct

Étape 5 : Faites la danse du ver des sables

Nous avons implémenté les planètes avec des avis en direct ! Faites une petite danse pour fêter ça avant de passer aux choses sérieuses.

Danse du ver

Partie 3 : Logique métier

Étape 1 : Ajouter le formulaire de saisie

Nous voulons un moyen de soumettre des avis via notre UI. Nous renommons notre formulaire de recherche en un InputForm générique et l'ajoutons au-dessus de la liste des avis.

import React, { useState } from "react";
import { useSubscription, gql } from "@apollo/client";
import { List, ListItem } from "./shared/List";
import { Badge } from "./shared/Badge";
import InputForm from "./shared/InputForm";

const PLANET = gql`
  subscription Planet($id: uuid!) {
    planets_by_pk(id: $id) {
      id
      name
      cuisine
      reviews(order_by: { created_at: desc }) {
        id
        body
        created_at
      }
    }
  }
`;

const Planet = ({
  match: {
    params: { id },
  },
}) => {
  const [inputVal, setInputVal] = useState("");
  const { loading, error, data } = useSubscription(PLANET, {
    variables: { id },
  });

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  const { name, cuisine, reviews } = data.planets_by_pk;

  return (
    <div>
      <h3>
        {name} <Badge>{cuisine}</Badge>
      </h3>
      <InputForm
        inputVal={inputVal}
        onChange={(e) => setInputVal(e.target.value)}
        onSubmit={() => {}}
        buttonText="Envoyer"
      />
      <List>
        {reviews.map((review) => (
          <ListItem key={review.id}>{review.body}</ListItem>
        ))}
      </List>
    </div>
  );
};

export default Planet;

Étape 2 : Tester la mutation d'avis

Nous utiliserons une mutation pour ajouter de nouveaux avis. Nous testons notre mutation avec GraphiQL dans la console Hasura.

Mutation d'insertion d'avis dans GraphiQL

Et nous la convertissons pour qu'elle accepte des variables afin de pouvoir l'utiliser dans notre code.

Mutation d'insertion d'avis avec variables

Étape 3 : Créer l'action

Les Bene Gesserit nous ont demandé de ne pas autoriser (tousse censurer tousse) le mot « fear » (peur) dans les avis. Nous créons une action pour la logique métier qui vérifiera la présence de ce mot chaque fois qu'un utilisateur soumet un avis.

Bouton « Derive action »

À l'intérieur de notre action fraîchement créée, nous allons dans l'onglet « Codegen ».

Onglet « Codegen »

Nous sélectionnons l'option nodejs-express et copions le code boilerplate du gestionnaire (handler) ci-dessous.

Code boilerplate pour nodejs-express

Nous cliquons sur « Try on Glitch », ce qui nous amène à une application express minimaliste, où nous pouvons coller notre code de gestionnaire.

Collage de notre code de gestionnaire dans Glitch

De retour dans notre action, nous définissons l'URL de notre gestionnaire sur celle de notre application Glitch, avec la route correcte provenant de notre code de gestionnaire.

URL du gestionnaire

Nous pouvons maintenant tester notre action dans la console. Elle s'exécute comme une mutation classique, car nous n'avons pas encore de logique métier vérifiant le mot « fear ».

Test de notre action dans la console

Étape 4 : Ajouter la logique métier

Dans notre gestionnaire, nous ajoutons une logique métier qui vérifie la présence de « fear » dans le corps de l'avis. S'il est sans peur (fearless), nous exécutons la mutation comme d'habitude. Sinon, nous renvoyons une erreur inquiétante.

Logique métier vérifiant « fear »

Si nous exécutons l'action avec « fear » maintenant, nous obtenons l'erreur dans la réponse :

Test de notre logique métier dans la console

Étape 5 : Ordonner les avis

L'ordre de nos avis est actuellement sens dessus dessous. Nous ajoutons une colonne created_at à la table reviews afin de pouvoir les classer du plus récent au plus ancien.

reviews(order_by: { created_at: desc })

Étape 6 : Ajouter la mutation d'avis

Enfin, nous mettons à jour la syntaxe de notre action avec des variables, et nous la copions-collons dans notre code en tant que mutation. Nous mettons à jour notre code pour exécuter cette mutation lorsqu'un utilisateur soumet un nouvel avis, afin que notre logique métier puisse vérifier sa conformité (ahem obéissance ahem) avant de mettre à jour notre base de données.

import React, { useState } from "react";
import { useSubscription, useMutation, gql } from "@apollo/client";
import { List, ListItem } from "./shared/List";
import { Badge } from "./shared/Badge";
import InputForm from "./shared/InputForm";

const PLANET = gql`
  subscription Planet($id: uuid!) {
    planets_by_pk(id: $id) {
      id
      name
      cuisine
      reviews(order_by: { created_at: desc }) {
        id
        body
        created_at
      }
    }
  }
`;

const ADD_REVIEW = gql`
  mutation($body: String!, $id: uuid!) {
    AddFearlessReview(body: $body, id: $id) {
      affected_rows
    }
  }
`;

const Planet = ({
  match: {
    params: { id },
  },
}) => {
  const [inputVal, setInputVal] = useState("");
  const { loading, error, data } = useSubscription(PLANET, {
    variables: { id },
  });
  const [addReview] = useMutation(ADD_REVIEW);

  if (loading) return <p>Chargement ...</p>;
  if (error) return <p>Erreur :(</p>;

  const { name, cuisine, reviews } = data.planets_by_pk;

  return (
    <div>
      <h3>
        {name} <Badge>{cuisine}</Badge>
      </h3>
      <InputForm
        inputVal={inputVal}
        onChange={(e) => setInputVal(e.target.value)}
        onSubmit={() => {
          addReview({ variables: { id, body: inputVal } })
            .then(() => setInputVal(""))
            .catch((e) => {
              setInputVal(e.message);
            });
        }}
        buttonText="Envoyer"
      />
      <List>
        {reviews.map((review) => (
          <ListItem key={review.id}>{review.body}</ListItem>
        ))}
      </List>
    </div>
  );
};

export default Planet;

Si nous soumettons un nouvel avis incluant « fear » maintenant, nous obtenons notre erreur inquiétante, que nous affichons dans le champ de saisie.

Test de notre action via l'UI

Étape 7 : On l'a fait ! 🎉

Félicitations pour avoir construit une application React & GraphQL Full-Stack !

High five

Que réserve l'avenir ?

spice_must_flow.jpg

Si seulement nous avions un peu de mélange d'épice, nous le saurions. Mais nous avons construit tellement de fonctionnalités en si peu de temps ! Nous avons couvert les requêtes GraphQL, les mutations, les abonnements, le routage, la recherche et même la logique métier personnalisée avec les actions Hasura ! J'espère que vous vous êtes amusé à coder avec moi.

Quelles autres fonctionnalités aimeriez-vous voir dans cette application ? Contactez-moi sur Twitter, et je ferai d'autres tutoriels ! Si vous êtes inspiré pour ajouter des fonctionnalités vous-même, n'hésitez pas à les partager – j'adorerais en entendre parler :)