Homepage

Build a ToDo List App

Last edit: Jul 14, 2023


Warning

This page is outdated. The examples in this guide still work, but you should check the new version of the Get Started guide here: https://documentation.platformos.com/get-started

Build a ToDo List App is the second part of our Get Started guide that walks you through the steps of creating a ToDo List app on platformOS from setting up your development environment to deploying and testing your finished app. It explains basic concepts, main building blocks, and the logic behind platformOS, while also giving you some recommendations on workflow.

Preview

You can preview the completed application you will be building based on this tutorial. Open this page and get yourself familiar with it — It will make it easier to imagine everything that will be described below.

Prerequisites

This tutorial assumes you have completed the Hello, World! tutorial.

The ToDo App will use a couple of basic building blocks available in platformOS:

  • Records with simple one-to-many relation
  • GraphQL Queries with basic filtering and sorting
  • GraphQL Mutations to update your database
  • Liquid filters
  • Liquid tags
  • Pages accepting GET, POST requests
  • Query parameters parsing
  • Static assets

Knowing most of the concepts will help you quickly understand what's going on. Knowledge of general web development concepts will also be helpful.

Frontend technology stack

For ease and speed of development a couple of tools have been used in this project.

  • Skeleton - Minimalistic CSS boilerplate
  • TailwindCSS - A utility-first CSS framework for rapidly building custom designs
  • Alpine.js - A minimal framework for composing JavaScript behavior in your markup

If you find HTML code snippets that contain some data attributes or enigmatic CSS classes, those are TailwindCSS and Alpine.js in action. The functionality will work just fine without them, but we wanted to show a more user friendly experience without using heavy JavaScript frameworks.

Application functionality

It is a good practice to write down exactly what you want to build before you start writing your first line of code. This will help you in multiple ways, but most importantly, it will allow you to follow a plan when implementing, which translates to saving a lot of time and avoiding distractions.

Your ToDo App will allow users to:

And this is the order in which you will be implementing the application. Additionally, you will create GraphQL operations first, then pages that will trigger them.

File structure

The flexibility of platformOS allows you to merge a lot of these files, but for educational purposes we kept them all separate and categorized in resource-named directories (list, item).

This is what the file structure looks like for this particular application:

app/
├── assets
│   ├── css
│   │   └── app.css
│   └── js
│       └── app.js
├── graphql
│   ├── index.graphql
│   ├── item
│   │   ├── create.graphql
│   │   ├── delete.graphql
│   │   ├── read.graphql
│   │   ├── update_completed.graphql
│   │   └── update_content.graphql
│   └── list
│       ├── create.graphql
│       ├── delete.graphql
│       ├── read.graphql
│       └── update.graphql
├── schema
│   ├── item.yml
│   └── list.yml
└── views
    ├── layouts
    │   └── application.liquid
    └── pages
        ├── index.liquid
        ├── item
        │   ├── create.liquid
        │   ├── delete.liquid
        │   └── update.liquid
        └── list
            ├── create.liquid
            ├── delete.liquid
            ├── show.liquid
            └── update.liquid

Defining Records

To have a list, which contains items, you will need two Records.

  1. List, with a title
  2. Item, with content, completed status and list ID that specifies which list it belongs to

There are also some built-in fields that you do not have to define yourself, like created_at, deleted_at, updated_at, and id. They are populated automatically in the object lifecycle.

Developing GraphQL queries and mutations

To develop GraphQL queries we strongly recommend using our pos-cli gui serve [environment] tool. This will open the GraphiQL tool which speeds up development a lot. It has autocomplete, built-in documentation, live feedback and many more useful features.

Screenshot of GraphiQL

GraphiQL demonstration

After you have tested a query and it has proven to work with hardcoded parameters, you can safely copy it to a target file and reference it using the graphql Liquid tag.

For example:

graphql/list/create.graphql will be referenced this way: {% graphql result = 'list/create', title: title %}

Layout and homepage

Before you start implementing features, you need some ground work. A basic layout and a homepage should be enough to create the form to create a new list.

app/views/layouts/application.liquid

<!DOCTYPE html>
<html lang="en">

<head>
<title>TODO</title>
<link rel="stylesheet" href="{{ 'css/app.css' | asset_url }}">
<style> form, input { margin: 0; } </style>
<script src="{{ 'js/app.js' | asset_url }}" defer></script>
</head>

<body>

<div class="container">
  <p class="mt-8 button">
    🏠 <a href="/" class="text-2xl">Todoleo <small class="text-gray-500">beta</small></a>
  </p>

  {{ content_for_layout }}
</div>
</body>
</html>

app/views/pages/index.liquid
Hello world

Now when you go to the root URL of your Instance, Hello world is displayed. Now you have a place to put your content.

Tables

To create an object in the Database you need to define its structure. In this case you need two Records.

app/schema/list.yml
name: list
properties:
  - name: title
    type: string
app/schema/item.yml
name: item
properties:
  - name: list_id
    type: string
  - name: content
    type: string
  - name: completed
    type: boolean

Item needs the list_id field to be able to relate it to a list when querying for list items.

Step 1: Create new list

All database operations in this application follow the same structure and have the same parts:

  1. GraphQL mutation

  2. Page that accepts parameters and forwards them to GraphQL

  3. HTML form that sends data to a page

app/graphql/list/create.graphql
 mutation create($title: String!) {
   record_create(
     record: {
       table: "list"
       properties: { name: "title", value: $title }
     }
   ) {
     id
   }
 }
app/views/pages/list/create.liquid

{% liquid
  assign title = context.params.title
  graphql result = 'list/create', title: title
  assign list = result | fetch: "record_create"
%}
{% if list.id %}
  <script>window.location.href = '/list/show/{{ list.id }}';</script>
{% else %}
  Something went wrong :(
  <br>
  {{ result.errors }}
{% endif %}

method: post allows you to accept post requests.

You are creating a list with a given title and using JavaScript to redirect the user to this list if there were no errors. If there is an error, the user will get an error returned by GraphQL. In a real application, you might want to handle this better.

app/views/pages/index.liquid
<form
  action="/list/create"
  method="POST"
  x-show="open === true">

  <input type="text" name="title" placeholder="List title" required>

  <button class="button-primary">Create</button>
</form>

The form action corresponds to the file location of the page from point 2. The page file is located in pages/list/create.liquid and its method is set to accept post, so you are sending a post request to the /list/create path. You also need to send the title field required by GraphQL, so you use input with type text and validate its existence client-side by adding the required attribute.

You can find a lot of data attributes (like x-data, x-show, @click) which are non standard. Those are Alpine.js directives that allow you to quickly add basic interactivity in HTML markup. No build process necessary. It is a perfect tool for this use case.

Step 2: View all lists

Now that you can create a list with a title, show 10 of them on your homepage, sorted by those which had been recently updated.

app/graphql/index.graphql
 query index {
   records(
     per_page: 10
     filter: {
       table: { value: "list" }
       properties: { name: "title", exists: true }
     }
     sort: { updated_at: { order: DESC } }
   ) {
     results {
       id
       title: property(name: "title")
     }
   }
 }
app/views/pages/index.liquid

 {% graphql lists = 'index' | fetch: 'records' | fetch: 'results' %}

 {% if lists.size > 0 %}
   <ol>
     {% for list in lists %}
       <li>
         <a href="/list/show/{{ list.id }}">{{ list.title }}</a>
       </li>
     {% endfor %}
   </ol>
 {% else %}
   <p>There are no lists yet. You can create the first one!</p>
 {% endif %}

You iterate over the array returned by GraphQL and inform the user that there is nothing to show if the array is empty.

Step 3: Display list

Now that you have a link pointing to /list/show/{{ list.id }}, you need to create this page to be able to show a particular list.

app/graphql/list/read.graphql
 query read($id: ID!) {
   records(
     per_page: 1
     filter: {
       table: { value: "list" }
       id: { value: $id }
     }
   ) {
     results {
       id
       title: property(name: "title")
     }
   }
 }
app/views/pages/list/show.liquid

 {% liquid
   assign id = context.params.slug3
   graphql list = 'list/read', id: id | fetch: 'records' | fetch: 'results' | first
 %}

 <h2 class="mb-0 mr-10">{{ list.title }}</h2>

These couple of lines of code pull the correct list object from the database and shows its title.

Step 4: Rename list

To rename a list, you need to update its title field. As usual, you need a GraphQL mutation, a page, and a form.

app/graphql/list/update.graphql
 mutation update($id: ID!, $title: String!) {
   record_update(
     id: $id
     record: { properties: { name: "title", value: $title } }
   ) {
     id
     title: property(name: "title")
   }
 }

Note that ID is required as well, because you have to tell the system which list has to be modified.

app/views/pages/list/update.liquid

 ---
 method: post
 ---
 {% liquid
   assign id = context.params.slug3
   assign title = context.params.title
   graphql result = 'list/update', id: id, title: title
   assign list = result | fetch: "record_update"
 %}

 {% if list.id %}
 <script>window.location.href = '/list/show/{{ id }}';</script>
 {% else %}
 Something went wrong :(
 <br>
 {{ result.errors }}
 {% endif %}

Errors are handled the same way as previously.

app/views/pages/list/show.liquid

<form
  method="POST"
  action="/list/update/{{ list.id }}"
  x-show="open === true"
  class="flex w-full mt-4">

  <input type="text" name="title" placeholder="New title" value="{{ list.title }}" class="w-2/3" required>

  <button class="ml-2 button-primary">Save</button>
</form>

Code for renaming a list is in the list/show.liquid page, that's why you have access to {{ list.id }}.

Step 5: Delete empty list

Because you did not connect any tasks to your lists, all of them are empty. This means you can easily implement a delete feature.

app/graphql/list/delete.graphql
 mutation delete($id: ID!) {
   record_delete(id: $id) {
     id
   }
 }
app/views/pages/list/delete.liquid

 ---
 method: post
 ---
 {% liquid
   assign id = context.params.slug3
   graphql result = 'list/delete', id: id
   assign list = result | fetch: "record_delete"
 %}

 {% if list.id %}
 <script>window.location.href = '/';</script>
 {% else %}
 Something went wrong :(
 <br>
 {{ result.errors }}
 {% endif %}

After deleting a list, the user will be redirected to the homepage.

app/views/pages/list/show.liquid

 {% if items.size > 0 %}
 ...
 {% else %}

 <p>
   Because this list is empty, you can remove it.
 </p>

 <form action="/list/delete/{{ list.id }}" method="POST">
   <button>Delete</button>
 </form>

 {% endif %}

You show the delete form only when there are no tasks associated with the list.

Step 6: Create task

Now that you have all your list operations ready, start handling items.

app/graphql/item/create.graphql
 mutation create($list_id: String!, $content: String!) {
   record_create(
     record: {
       table: "item"
       properties: [
         { name: "list_id", value: $list_id }
         { name: "content", value: $content }
       ]
     }
   ) {
     id
     list_id: property(name: "list_id")
     content: property(name: "content")
   }
 }
app/views/pages/item/create.liquid

 ---
 method: post
 ---
 {%
   assign list_id = context.params.slug3
   assign content = context.params.content
   graphql result = 'item/create', list_id: list_id, content: content
   assign item = result | fetch: "record_create"
 %}

 {% if item.id %}
 <script>window.location.href = '/list/show/{{ list_id }}';</script>
 {% else %}
 Something went wrong :(
 <br>
 {{ result.errors }}
 {% endif %}

To create a task attached to a list, you need the list's ID. It is passed in the URL and accessed as context.params.slug3. After creation, the user is redirected back to the list.

app/views/pages/list/show.liquid

<form
  action="/item/create/{{ list.id }}"
  method="POST"
  x-show="open === true">

  <input type="text" name="content" placeholder="Your task" required>

  <button class="button-primary">Create</button>
</form>

Step 7: Mark task as complete

app/graphql/item/update_completed.graphql
 mutation update($id: ID!, $completed: Boolean!) {
   record_update(
     id: $id
     record: {
       properties: [
         { name: "completed", value_boolean: $completed }
       ]
     }
   ) {
     id
   }
 }
app/views/pages/item/update.liquid

 ---
 method: post
 ---
 {%- assign id = context.params.slug3 -%}

 {% if context.params.completed %}
   {%- assign completed = context.params.completed | matches: '1' -%}
   {% graphql result = 'item/update_completed', id: id, completed: completed %}
 {% endif %}

 {% assign item = result | fetch: "record_update" %}

 {% if item.id %}
 <script>window.location.href = '/list/show/{{ context.params.list_id }}';</script>
 {% else %}
 Something went wrong :(
 <br>
 {{ result.errors }}
 {% endif %}

GraphQL is expecting Boolean, so you need to convert "1" to true. If the completed parameter doesn't have "1" inside of it, the completed variable will resolve to false, and the task will be marked as uncompleted.

app/views/pages/list/show.liquid

<form
 action="/item/update/{{ item.id }}"
 method="POST"
 class="text-center">

 <input type="hidden" name="completed" value="0">
 <input
   type="checkbox"
   name="completed"
   value="1"
   onchange="this.form.submit()"
   class="w-8 h-8"
   {% if item.completed == true %}checked{% endif %}
 />
 <input type="hidden" name="list_id" value="{{ list.id }}">
</form>

Knowledge of HTML forms and tricks around them helps keep your code clean here.

To have two states using checkbox, you are sending a "0" value to the server (hidden field). If the user checks the checkbox (note: the same name, "completed". Unchecked checkbox value is not sent), then the last input of that particular name takes precedence.

This means: If a form is submitted when the checkbox is checked, the value from the checkbox is sent (completed: "1"). If the user did not check the checkbox (or unchecked it), the value of the hidden field is sent (completed: "0").

Additionally, you submit the form as soon as the user checks/unchecks the checkbox using the onchange attribute and a little JavaScript, so a submit button is not necessary.

Step 8: Edit a task's content

app/graphql/item/update_content.graphql
 mutation update($id: ID!, $content: String!) {
   record_update(
     id: $id
     record: {
       properties: [
         { name: "content", value: $content }
       ]
     }
   ) {
     id
   }
 }
app/views/pages/item/update.liquid

 ---
 method: post
 ---
 {% liquid
   assign id = context.params.slug3
   assign content = context.params.content
 %}

 {% if content.size > 0 %}
   {% graphql result = 'item_update_content', id: id, content: content %}
 {% endif %}

 {% assign item = result | fetch: "record_update" %}

 {% if item.id %}
 <script>window.location.href = '/list/show/{{ context.params.list_id }}';</script>
 {% else %}
 Something went wrong :(
 <br>
 {{ result.errors }}
 {% endif %}

You might have noticed that the above filename is the same as in the one in the previous step. Because it is, we just separated it for the purposes of this tutorial, but in source code and in real application, both updates are handled in the same page, using one if statement.

app/views/pages/list/show.liquid

<form
 method="POST"
 action="/item/update/{{ item.id }}"
 x-show="open === true"
 class="flex w-full">

  <input type="text" name="content" value="{{ item.content }}" required class="w-1/2" />
  <input type="hidden" name="list_id" value="{{ list.id }}">

 <button class="ml-2 button-primary">save</button>
</form>

Step 9: Delete a task

app/graphql/item/delete.graphql
 mutation delete($item_id: ID!) {
   record_delete(id: $item_id) {
     id
   }
 }
app/views/pages/item/delete.liquid

 ---
 method: post
 ---
 {%- assign item_id = context.params.slug3 -%}
 {%- assign list_id = context.params.list_id -%}

 {% graphql result = 'item_delete', item_id: item_id %}
 {% assign item = result | fetch: "record_delete" %}

 {% if item.id %}
 <script>window.location.href = '/list/show/{{ list_id }}';</script>
 {% else %}
 Something went wrong :(
 <br>
 {{ result.errors }}
 {% endif %}

app/views/pages/list/show.liquid

<form action="/item/delete/{{ item.id }}" method="POST">
  <input type="hidden" name="list_id" value="{{ list.id }}">
  <button>delete</button>
</form>

By now you are probably familiar with the steps required.

Source code

Warning

The code included in this tutorial is not complete. It showcases every concept once to present the idea. To have a working example go to the source code and deploy it to your own Instance.

Take a look at the source code. Feel free to clone it, deploy it to your own Instance, and modify/extend it to build your own practical skillset on platformOS.

Questions?

We are always happy to help with any questions you may have.

contact us