Build a ToDo List App - Part 2: Extend Your App

Last edit: Jun 24, 2022

This is part 2 of the Build a ToDo List App series. In this tutorial, you will extend what you have built in part one to explore additional platformOS features in greater depth and learn about more best practices.


You can preview the completed application you will be building based on this tutorial.


This tutorial assumes you have completed the Build a ToDo List App tutorial. The code included in this tutorial is not complete. To start with a working example go to the source code and deploy it to your own Instance.


File structure

In this tutorial, you will have to reorganize some files because some pages became too long and complicated to fit on the screen.

You will extract parts of the features that can be easily named to partials — notice new main directories for partials and email notifications.

├── assets
│   ├── css
│   │   └── app.css
│   └── js
│       └── app.js
├── graphql
│   ├── emails
│   │   └── created_protected_list.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
├── emails
│   └── created_protected_list.liquid
└── views
    ├── layouts
    │   ├── application.liquid
    │   └── mailer.liquid
    ├── pages
    │   ├── index.liquid
    │   ├── item
    │   │   ├── create.liquid
    │   │   ├── delete.liquid
    │   │   └── update.liquid
    │   └── list
    │       ├── create.liquid
    │       ├── delete.liquid
    │       ├── show.liquid
    │       └── update.liquid
    └── partials
        └── list
            ├── item.liquid
            ├── new_item.liquid
            ├── remove.liquid
            ├── show.liquid
            ├── title.liquid
            └── wrong_password.liquid

Step 1: Adding new properties to an existing record

Adding new properties to a Record is identical to creating a new Record. Add properties with types in your Record definition and sync the file to the server.

name: list
 - name: title
   type: string
+- name: password
+  type: string
+- name: email
+  type: string

To test if the properties have been added you can use GraphQL. We chose the admin_tables query to list attributes and their types of a given Table, in this type, the list schema.

query record_props {
  admin_tables(filter: {
    name: { value:"list" }
  }) {
    results {
      properties {

Executing this query resulted in:

  "data": {
    "admin_tables": {
      "results": [
          "name": "list",
          "properties": [
              "name": "email",
              "attribute_type": "string"
              "name": "password",
              "attribute_type": "string"
              "name": "title",
              "attribute_type": "string"

Which means that the password and email fields have been added to the Table.

Step 2: Adding password protection

For password protection you are going to use the simplest possible way of doing it. You will keep the password in plain text in the database (usually not recommended), pass it in the URL as a GET request (usually highly not recommended) and send it using a hidden text field in a POST request. This is because this tutorial is not supposed to be a demonstration of good security practices - we will cover security measures in a separate series of articles. This excercise is about expanding your existing application and providing advice on how to use features available in an efficient manner for speedy application development.

You need to adapt your list creation query to accept more arguments. Those fields are not required, so type is not suffixed with !.

+mutation create($title: String!, $email: String, $password: String) {
    record: {
      table: "list"
      properties: [
        { name: "title", value: $title }
+       { name: "email", value: $email }
+       { name: "password", value: $password }
  ) {

Using the grahpql tag with args

Having your query updated, you need to modify your create page to accept more arguments. Because the arguments list can become long and it might be easier to keep all those arguments in one object, we designed the graphql tag in a "liberal input, strict output" manner.

Modify your create page to account for new arguments and refactor code to make it easier to understand.


{% parse_json arguments %}
  "title": "{{ context.params.title }}",
+  "email": "{{ context.params.email }}",
+  "password": "{{ context.params.password }}"
{% endparse_json %}

+{% graphql result = 'list/create', args: arguments %}

Changes made to this file:
First, you add new properties to the arguments hash.
Next, you pass them all to the list/create query. Their names need to match in the arguments hash and the graphql query.

Index of lists

To make it obvious for users which lists are password protected and which are not, mark them with a lock emoji.


-<a href="/list/show/{{ list.id }}">{{ list.title }}</a>
+<a href="/list/show/{{ list.id }}">
+  {% if list.password %}🔒{% endif %}
+  {{ list.title }}

To make it work, the query fetching lists needed password added to the results.

results {
  title: property(name: "title")
+  email: property(name: "email")
+  password: property(name: "password")

Creating a password protected list

To create a password protected list, use a checkbox to show new fields (email, password) that will be sent over with form.

It all happens on the home page where you create the new list.


-<div x-data="{ open: false }">
+<div x-data="{ open: false, protected: false }" class="p-8 bg-gray-200 rounded-lg">
     @click.prevent="open = !open"
     x-show="open !== true"
@@ -24,8 +33,38 @@
     x-show="open === true">
-    <input type="text" name="title" placeholder="List title" required>
-    <button class="button-primary">Create</button>
+    <h4>New list</h4>
+    <fieldset>
+      <label>
+        Title
+        <br>
+        <input type="text" name="title" placeholder="List title" required>
+      </label>
+    </fieldset>
+    <fieldset>
+      <label for="protected" class="inline-flex items-center">
+        <input type="checkbox" name="protected" id="protected" @change="protected = !protected" :checked="protected" class="w-6 h-6 mr-2 form-checkbox">
+        Password protected
+      </label>
+    </fieldset>
+    <fieldset :disabled="protected !== true" x-show="protected === true">
+      <label>
+        Password
+        <br>
+        <input type="text" name="password" placeholder="Password" required>
+      </label>
+      <label>
+        Email
+        <br>
+        <input type="text" name="email" placeholder="Your email" required>
+      </label>
+    </fieldset>
+    <button class="mt-4 button-primary">Create</button>
     <button @click.prevent="open = false">Cancel</button>

You added a new variable in alpinejs named protected and binded it to a checkbox. If it's checked, the email and password fields are shown and required to be filled.

Accessing a password protected list

Now you need to handle checking the password for correctness and showing the list or not depending on the result.


{% graphql list = 'list/read', id: id | fetch: 'records' | fetch: 'results' | first %}

{% if list.password.size > 0 %}
+  {% if context.params.password == list.password %}
+    {% include 'list/show' %}
+  {% else %}
    {% include 'list/wrong_password' %}
  {% endif %}
{% else %}
+  {% include 'list/show' %}
{% endif %}


If the list has no password, the server will render it.

If it has a password, it will check if the password passed from query params is the same as the one saved in the database.
If the passwords match, it will render the list. If not, it will render the wrong_password partial.


Because you verify the password from query params with database, every operation that redirects back to the list, has to have the password query param now.

Some of your pages are redirecting back to the list page. For example, mark item as completed, or change title, or add an item.

This is how you have been redirecting thus far:

<script>window.location.href = '/list/show/{{ list.id }}';</script>

And this is how the added query param with the password looks like:

<script>window.location.href = '/list/show/{{ list.id }}?password={{ context.params.password }}';</script>

To have the password available in context.params, it needs to be passed somehow. Use hidden inputs to pass it around.

+ <input type="hidden" name="password" value="">

This will work on most pages, because you had to have the password in the URL to display the list in the first place. From there, you just need to make sure every operation is keeping this password in the URL so it can be reused. In the real world, you would probably use JWT authentication to avoid exposing the password in the URL.

Wrong password

If the user provided an incorrect password, inform them about it and present the password input again.

  Wrong password. Access denied.

<div class="p-8 bg-gray-200 rounded-lg">
  <form action="/list/show/{{ list.id }}">
      <input type="text" name="password" value="{{ context.params.password }}" placeholder="Password">


Step 3: Adding an email notification

In part 3 of our series, you will add all notification types there are, including SMS and webhook. Here, you will only send an email to the creator with the link of the list and the password. Read more about email notifications, especially the part about enabling sending real emails from staging. You will need it, so make sure test_email has been populated in the Partner Portal.

Creating the notification

An email notification's format is similar to that of a page. It has a YAML part and content part, and some parts are required (to, subject, content).


to: "{{ data.email }}"
from: [email protected]
reply_to: [email protected]
subject: "List \"{{ data.title }}\" has been created"
layout: mailer

<h2>Your list has been created</h2>

  List name: {{ data.title }} <br>
  List password: {{ data.password }} <br>
  <a href="https://{{ context.location.host }}/list/show/{{ data.id }}?password={{ data.password }}">Preview list</a>

The data object is an object that will be passed to the notification from the mutation that will trigger it.

  • "{{ data.email }}" is in quotes because the Liquid code has to be quoted in YAML.
  • "List \"{{ data.title }}\" has been created" - In this case you had to quote for the Liquid code and quote for our message - that's why we had to escape inside quotes with \. This way YAML is ignoring those quotes.


layout works the same as layout for pages and it is layout for email notifications, in this case named mailer. You can name it however you like. Explore its

Triggering notification using GraphQL

Sometimes notifications are triggered from forms, in this example you will use email_send mutation to trigger it when you want to. Triggering in forms means it will be sent on successful submit, given the trigger_condition is true (or liquid inside of it resolves to true).

Using the mutation inside the if condition is similar to using form and doing a condition check in trigger_condition.

This mutation needs two things to work properly. One is the notification path, in this case created_protected_list, because the file containing it is named created_protected_list relative to email_notifications. The second thing is object with data that is expected to be present inside the notification. The query will work if it's not present, but notification will not be sent, because it will not have a target email to send to.

An email that will go out will be sent from the SendGrid infrastructure to the email you put into test_email in Partner Portal. Its subject will contain the email that it would be sent to if it was working in production mode.

mutation notify($data: HashObject) {
    template: { name: "created_protected_list" }
    data: $data
  ) {
    errors { message }

is_scheduled_to_send is a result of this mutation. Basically it tells you if the operation was successful and if the notification is scheduled to be sent or not. If not, an object with errors will have a message that will tell you what went wrong.

Adding new key to existing hash

You might have noticed that our notification includes a preview link. You need to pass the list id to the notification to create this link.

<a href="https://{{ context.location.host }}/list/show/{{ data.id }}?password={{ data.password }}">Preview list</a>

Because the arguments object in the list create page did not have a list, until it is created (you cannot have an id of a non-existent row), you need to add it to arguments after the list has been created. You will use the add_hash_key to do that.


{% if list.id %}
+  {% assign data = arguments | add_hash_key: 'id', list.id %}
+  {% if arguments.password and arguments.email %}
+    {% graphql notify = 'emails/created_protected_list', data: data %}
+  {% endif %}
+  <script>window.location.href = '/list/show/{{ list.id }}?password={{ context.params.password }}';</script>

Having all the information you need for the notification to be sent, you trigger the emails/created_protected_list mutation and redirect the user to the list.

Using logs for your advantage

Very often during software development, something is not right. Things don't work or they work in unexpected ways. Or you want to just keep track of the data flow during your testing. This is when logging can help you track down a bug or make sense of unexpected application behavior. You can use logs inside pages, layouts, partials, notifications, properties that accept Liquid in YAML parts of the system.

platformOS has the log liquid tag that you can use to record any data into the log stream. To preview the contents of the log stream, run pos-cli logs <environment> in your terminal.

For your purposes you want to track when someone tried to access a list with wrong password, given that password has been given (is not empty).


{% if list.password.size > 0 %}
  {% if context.params.password == list.password %}
    {% include 'list/show' %}
  {% else %}
    {% include 'list/wrong_password' %}

+    {% if context.params.password %}
+      {% capture message -%}
+        [{{ id }}] {{ list.title }} - {{ context.params.password }}
+      {%- endcapture %}
+      {% log message, type: 'wrong-password' %}
+    {% endif %}
  {% endif %}
{% else %}


First, you check if the password has been passed in the URL query. If there is a password, server will do some work. You do not want to log empty passwords, because you want to detect hacking attempts.

Next, you compose a message using the capture tag, using it as a template (think template literals from JavaScript). You save to a message variable string with the id of the list, list title and password passed, that is incorrect.

Finally, you use the log tag to save this message to logs under the type wrong-type to be easily identified.

An example log entry looks like this:

[2020-03-17 17:25:36.964Z] - wrong-password: [36] My super secret list - schrute.farm.best.beets

You can just as well log objects, strings, arrays, booleans.

For example:

{% parse_json res %}
  "errors": ["no", "errors", "present"],
  "status": 200,
  "message": "OK",
  "context": {
    "host": "{{ context.location.host }}"
{% endparse_json %}

{% log res, type: "response" %}

This will result in logging:

[2020-03-18 22:18:24.902Z] - response: {"errors":["no","errors","present"],"status":200,"message":"OK","context":{"host":"todo-app-dev2.staging.oregon.platform-os.com"}}

Using parse_json as an object creation tool is very powerful when you need to log a whole data structure.

Read more about logging.


This is most of the work you needed to achieve your goals, but not all of them. Make sure you look at the final difference between master and 2-extending branches to inspect on GitHub every code change that happened to get to the final state from part one of this tutorial series.

We are advising that because the file structure changed and it would make a very long and boring article to show how it changed. Extraction of code is something you will feel the need naturally when your files grow.

Source code

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.

Contribute to this page

Github Icon


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

contact us