DEV Community

Cover image for Writing a graphQL fullstack Svelte Web App with Sapper & Postgraphile
Denis Donici
Denis Donici

Posted on

Writing a graphQL fullstack Svelte Web App with Sapper & Postgraphile

Writing a graphQL fullstack Svelte Web App with Sapper & Postgraphile

Finally I've got some free time for writing, so today I would like to share with you a simple yet powerfull way to write full stack web apps using my favorite tools:

  • Sapper/Svelte
  • Postgres/SQL
  • Postgraphile/graphQL

A friend of mine asked me if I could help him with some boring paperwork stuff by creating a internal CRM for his medical office. I've decided to give it a shot. You can check the repo and see the internals if you don't have time for lenghy explanation or if you are intersted in details - read on. The project consists of a client list with personal data (like, dob, telephone etc.) and also a monthly medical intake data for each client like WBC, RBC, HBG, HCT etc.

Alt Text

Postgres

First thing first, we will start with creating a database in Postgres and defining tables for our usecase.

sudo -u postgres psql
CREATE USER admin WITH PASSWORD 'adminpassword';
CREATE DATABASE medical_db OWNER admin;
\c medical_db admin localhost 5432

CREATE TABLE clients (
    client_id SERIAL PRIMARY KEY,
    first_name text NOT NULL,
    last_name text NOT NULL,
    telephone text NOT NULL UNIQUE,
    gender varchar(6) NOT NULL CHECK (gender IN ('MALE', 'FEMALE')),
    active boolean NOT NULL DEFAULT TRUE,
    dob date NOT NULL
);

CREATE TABLE intake (
    intake_id SERIAL PRIMARY KEY,
    client_id INTEGER NOT NULL REFERENCES clients (client_id),
    weight decimal(4,1) NOT NULL,
    wbc decimal(4,1) NOT NULL,
    rbc decimal(4,1) NOT NULL,
    hbg decimal(4,1) NOT NULL,
    hct decimal(4,1) NOT NULL CHECK (hct >= 0 AND hct <= 100),
    intake_time timestamp DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

If you dont know SQL, it is a shame. Even though it might not seem to be as fancy or cool like modern day ORMs, don't be decieved, because SQL is very very powerfull language. The fact is, the ORMs are not even 20% close to all the capabilities of SQL. Learn it. It can do magic.

Setting up Sapper

Now let's move on and create the basis for our Sapper app.

npx degit "sveltejs/sapper-template#rollup" medical-app
cd medical-app
npm install
Enter fullscreen mode Exit fullscreen mode

We have just set up the default project. It comes with some boilerplate stuff that we won't need.
Get rid of all the default styles in /static/src/global.css
Also, delete all the files that are in /src/routes and /src/components folders.

Now, we won't style the project ourself. Will let this little file do it for us

<link rel="stylesheet" href="https://unpkg.com/mvp.css">
Enter fullscreen mode Exit fullscreen mode

Just ad this to the head of the /src/template.html file

MVP.css is a minimalist CSS framework no class names at all. I usually use it to save time for quick prototype styling. I like it for it's simplicity and elegant design.

Setting up Postgraphile

Great. Now here comes my favorite part, Postgraphile.
This thing is a beast in itself and I totally recommend checking it out. What Postgraphile actually does, it reads the definition of the tables that we have created previously, and creates automagicaly queries and mutations so we don't have to write them manualy ourselves. Isn't that cool? Besides that, it provides us with a frontend UI that we will use later to check our queries. It is battle tested piece of software and has a very welcoming and great team behind it.

Let's install Postgraphile

npm i postgraphile
Enter fullscreen mode Exit fullscreen mode

For queries and mutations I use awesome @urql client for Svelte. It is light and intuitive to work with.

npm i -D @urql/svelte graphql 
Enter fullscreen mode Exit fullscreen mode

In order to make postgraphile work, import into the /src/server.js file

import { postgraphile } from 'postgraphile';
Enter fullscreen mode Exit fullscreen mode

and add Postgraphile as middleware for polka

...
polka()
    .use(
    postgraphile(
               process.env.DATABASE_URL || "postgres://admin:adminpassword@localhost:5432/medical_db",
               "public",
               {
                 watchPg: true,
                 graphiql: true,
                 enhanceGraphiql: true,
               }
             ),
      compression({ threshold: 0 })
...

Enter fullscreen mode Exit fullscreen mode

That's it.

Testing Postgraphile

Now if you start the server with

npm run dev
Enter fullscreen mode Exit fullscreen mode

and visit the

localhost:3000/graphiql

route you sould be greeted with a graphiql, a web interface for queries and mutations that we talked about earlier.

Let's make sure queries and mutations are working properly.
We will fetch some non existent clients.

query MyQuery {
  allClients {
    nodes {
      firstName
      lastName
      clientId
      nodeId
      telephone
      dob
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Abviously the result is an empty array since we haven't added any clients yet. Let's quickly add one.

Alt Text

Fetch it again and voila.

We got a graphql server working on Sapper with very little work. The only thing is left now is to create some interface so we can interact with our database using graphQL and lovely Svelte

Svelte & graphQL with @urql/svelte

We do not have any routes yet and no layout for them. So let's make some.

I have created the following routes:

  • index.svelte
  • /clients/newclient.svelte
  • /clients/[slug]/index.svelte
  • /clients/[slug]/intakes/newintake.svelte
  • /clients/[slug]/intakes/[slug].svelte

Also in the /src/routes folder we need a layout.svelte file with a slot wrapped in the main tags

<script>
  import Nav from "../components/Nav.svelte";
  import {  createClient, setClient, fetchExchange } from "@urql/svelte";

  const client = createClient({
    url: "http://localhost:3000/graphql",
    exchanges: [fetchExchange],
  });

  setClient(client);
</script>
<Nav />
<main>
    <slot></slot>
</main>
Enter fullscreen mode Exit fullscreen mode

and the Nav.svelte in the components folder

<nav>
  <ul>
    <li>
      <a href="/">Clients</a>
    </li>
    <li>
      <a href="/clients/newclient">Add a client</a>
    </li>
    <li>
      <a href="graphiql">Postgraphile</a>
    </li>
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode

Notice that we have also initialized the @urql svelte client pointing to our Postgraphile graphql endpoint in the layout page, so we can access it from any route.

The main index.svelte

<script>
  import {
    operationStore,
    query,
  } from "@urql/svelte";

  import ClientsTable from "../components/ClientsTable.svelte";

  const clients = operationStore(
    `
    query MyQuery {
      allClients {
        nodes {
          clientId
          firstName
          lastName
          active
          telephone
          intakesByClientId {
            totalCount
          }
        }
      }
    }
    `,
    { requestPolicy: "cache-first" }
  );

  query(clients);

  const refresh = () => ($clients.context = { requestPolicy: "network-only" });
</script>

<svelte:head>
  <title>Postgraphile Clients</title>
</svelte:head>
<section>
  <header>
    <h1>Client List</h1>
    <button on:click={refresh}>refresh</button>
  </header>

  {#if $clients.fetching}
    <h2>Loading...</h2>
  {:else if $clients.error}
    <h2>Oh no... {$clients.error.message}</h2>
  {:else}
    <ClientsTable clients={$clients?.data?.allClients?.nodes} />
  {/if}
</section>
Enter fullscreen mode Exit fullscreen mode

Creating a form for new client

In /clients/newclient.svelte lets import a component with a form for client creation

<script>
  import ClientForm from "../../components/ClientForm.svelte";
</script>

<ClientForm />
Enter fullscreen mode Exit fullscreen mode

And here is the actual component

<script>
  import { mutation } from "@urql/svelte";
  import { onMount } from "svelte";
  import { goto } from "@sapper/app";

  const createClientMutation = mutation({
    query: `
      mutation MyMutationCreate(
          $firstName: String!, 
          $lastName: String!, 
          $telephone: String!, 
          $gender: String!, 
          $dob: Date!,
          $active: Boolean!
          ) {
            createClient(
                    input: {
                    client: {
                        firstName: $firstName
                        lastName: $lastName
                        telephone: $telephone
                        gender: $gender
                        dob: $dob,
                        active: $active
                    }
                    }
                ) {
                    client {
                    firstName
                    lastName
                    telephone
                    nodeId
                    clientId
                    active
                    }
            }
      }
    `,
  });

  const updateClientMutation = mutation({
    query: `
      mutation MyMutationUpdate(
          $id: Int!,
          $firstName: String, 
          $lastName: String, 
          $telephone: String, 
          $gender: String, 
          $dob: Date,
          $active: Boolean
          ) {
            updateClientByClientId(
                input: {
                clientPatch: {
                    active: $active
                    clientId: $id
                    dob: $dob
                    gender: $gender
                    firstName: $firstName
                    lastName: $lastName
                    telephone: $telephone
                }
                clientId: $id
                }
            ) {
                client {
                firstName
                lastName
                }
            }
      }
    `,
  });

  let clt = {
    firstName: "",
    lastName: "",
    telephone: "",
    dob: "",
    gender: "",
    active: true,
  };

  export let originalClient;

  const createNewClient = async () => {
    const responseCreate = await createClientMutation({
      ...clt,
      dob: new Date(clt.dob),
    });
    // console.log(responseCreate);
    if (responseUpdate.data) {
      goto("/");
    }
  };

  const updateClient = async () => {
    const responseUpdate = await updateClientMutation({
      ...clt,
      id: originalClient.clientId,
      dob: new Date(clt.dob),
    });
    // console.log(responseUpdate);
    if (responseUpdate.data) {
      goto("/");
    }
  };

  const handleClient = async () => {
    originalClient ? updateClient() : createNewClient();
  };

  onMount(() => {
    if (originalClient) {
      clt = Object.assign({}, originalClient);
    }
  });
</script>

<section>
    <form on:submit|preventDefault={handleClient}>
      <div class="grd">
          <label for="fistName"
            >First Name

            <input type="text" name="firstName" required bind:value={clt.firstName} />
          </label>
          <label for="lastName"
            >Last Name

            <input type="text" name="lastName" required bind:value={clt.lastName} />
          </label>
          <label for="telephone"
            >Telephone

            <input type="tel" name="telephone" required bind:value={clt.telephone} />
          </label>
          <label for="dob"
            >Date of Birth

            <input type="date" name="dob" required bind:value={clt.dob} />
          </label>
          <label for="gender"
            >Gender

            <select name="gender" required bind:value={clt.gender}>
              <option value="" disabled selected>-</option>
              <option value="MALE">Male</option>
              <option value="FEMALE">Female</option>
            </select>
          </label>
          <label for="active"
            >Active

            <input type="checkbox" bind:checked={clt.active} />
          </label>
      </div>
      <br />
      {#if originalClient}
        <button type="submit">Update client</button>
      {:else}
        <button type="submit">Add client</button>
      {/if}
    </form>
</section>

<style>
    .grd {
        display: grid;
        grid-template-columns: 1fr 1fr;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

If you pay attention we have here to mutaions, create and update client. This is because we are reusing the same form for both cases.

Add the table of clients

Here is ClientsTable.svelte component that we are importing in the main index.svelte. We are expecting an array of clients. We loop thru it via #each clients as client and display desired data

<script>
  export let clients = [];
</script>

<table>
  <tr>
    <th>Name</th>
    <th>Status</th>
    <th>Phone number</th>
    <th>Intakes</th>
    <th />
  </tr>
  {#each clients as client (client.clientId)}
    <tr>
      <td>{client?.firstName} {client?.lastName}</td>
      <td>{client?.active ? "ACTIVE" : "INACTIVE"}</td>
      <td><a href={`tel:${client?.telephone}`}>{client?.telephone}</a></td>
      <td>{client?.intakesByClientId?.totalCount}</td>
      <td><a href={`clients/${client.clientId}`}><button>More</button></a></td>
    </tr>
  {/each}
</table>

<style>
  table {
    margin: 0 auto;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Edit/Slug page of a client

<script>
  import ClientForm from "../../../components/ClientForm.svelte";

  import { stores } from "@sapper/app";
  const { page } = stores();

  import { operationStore, query } from "@urql/svelte";
  import { onMount } from "svelte";
  import IntakeTable from "../../../components/IntakeTable.svelte";

  const client = operationStore(
    `
    query MyQuery($id: Int!) {
        clientByClientId(clientId: $id) {
            firstName
            lastName
            dob
            gender
            telephone
            active
            clientId
            intakesByClientId {
                nodes {
                    intakeId
                    intakeTime
                    rbc
                }
            }
        }
    }
    `,
    { id: parseInt($page.params.slug) },
    { requestPolicy: "cache-first" }
  );

  query(client);

  const refresh = () => ($client.context = { requestPolicy: "network-only" });

  onMount(() => {
    refresh();
  });

  $: clientIntakes = $client.data?.clientByClientId.intakesByClientId?.nodes
</script>

<div class="rght">
    <a href={`/clients/${$page.params.slug}/intakes/newintake`}
    ><button>New Intake</button></a
    >
</div>
{#if $client.fetching}
  <h2>Loading...</h2>
{:else if $client.error}
  <h2>Oh no... {$client.error.message}</h2>
{:else}
  <div class="flx">
    <ClientForm originalClient={$client.data?.clientByClientId} />
    <IntakeTable {clientIntakes} clientId={$page.params.slug}/>
  </div>
{/if}


<style>
    .rght {
        text-align: right;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

We are reusing the ClientForm for editing. Notice how we pass the slug into the client query as id.

New form for medical data input

In our newintake route we are adding IntakeForm.svelte component and pass to it the clientId from page.params.slug
here is the component itself

<script>
  import { mutation } from "@urql/svelte";
  import { goto } from "@sapper/app";

  export let clientId;

  let intakeData = {
    wbc: "",
    rbc: "",
    hbg: "",
    hct: "",
    weight: "",
  };

  const createIntakeMutation = mutation({
    query: `
        mutation MyMutation($id: Int!, $wbc: BigFloat!, $rbc: BigFloat!, $hbg: BigFloat!, $hct: BigFloat!, $weight: BigFloat!) {
        createIntake(
            input: {intake: {clientId: $id, wbc: $wbc, rbc: $rbc, hbg: $hbg, hct: $hct, weight: $weight}}
        ) {
            intake {
              intakeId
            }
        }
        }`,
  });
  const createNewIntake = async () => {
    const responseCreate = await createIntakeMutation({
      id: parseInt(clientId),
      ...intakeData,
    });
    // console.log(responseCreate);
    if (responseCreate.data) {
      goto(`/clients/${clientId}`);
    }
  };
</script>

<section>
  <h1>New Intake</h1>
  <form on:submit|preventDefault={createNewIntake}>
    <label for="wbc"
      >White blood cells
      <input type="number" name="wbc" step="0.1" required bind:value={intakeData.wbc} />
    </label>
    <label for="rbc"
      >Red blood cells
      <input type="number" name="rbc" step="0.1" required bind:value={intakeData.rbc} />
    </label>
    <label for="hbg"
      >HBG
      <input type="number" name="hbg" step="0.1" required bind:value={intakeData.hbg} />
    </label>
    <label for="hct"
      >HCT
      <input
        type="number"
        name="hct"
        required
        step="0.1" 
        bind:value={intakeData.hct}
        min="0"
        max="100"
      />
    </label>
    <label for="weight"
      >Weight
      <input type="number" step="0.1"  name="weight" bind:value={intakeData.weight} />
    </label>
    <button type="submit">Send</button>
  </form>
</section>

<style>
  section {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    justify-content: center;
    align-items: center;
  }
</style>

Enter fullscreen mode Exit fullscreen mode

It is intersting to note that the mutation here for the variables is set to BigInt since we defined them in the intake schema as decimal. We have set the input to step="0.1"

Alt Text

And that is it. I hope you have learned something new. If you have any suggestions and know how to improve the code, please hit me up. I will be glad to hear from you. It was quite easy to make a complete, full stack app with Sapper. Postgraphile here does the whole magic under the hood. I would like to thank Bengie for it. Cheers!

Discussion (2)

Collapse
samuelstroschein profile image
Samuel Stroschein • Edited

Hi Denis,
sorry unrelated to this post but I haven't found another way to reach out to you. You starred my inlang repo on github. I wonder what caught your interest/how to improve the product. It would be awesome if you can comment in the github discussion github.com/samuelstroschein/inlang....

Best,
Samuel :)

Collapse
gevera profile image
Denis Donici Author

I did =)