Build a ToDo List App - Part 2: Extend Your App
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
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.
Preview
You can preview the completed application you will be building based on this tutorial.
Prerequisites
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.
Steps
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.
app/
├── 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.
app/schema/list.yml
name: list
properties:
- 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 {
name
properties {
name
attribute_type
}
}
}
}
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 !
.
app/graphql/list/create.graphql
+mutation create($title: String!, $email: String, $password: String) {
record_create(
record: {
table: "list"
properties: [
{ name: "title", value: $title }
+ { name: "email", value: $email }
+ { name: "password", value: $password }
]
}
) {
id
}
}
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.
app/views/pages/list/create.liquid
{% 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.
app/views/pages/index.liquid
-<a href="/list/show/{{ list.id }}">{{ list.title }}</a>
+<a href="/list/show/{{ list.id }}">
+ {% if list.password %}🔒{% endif %}
+ {{ list.title }}
+</a>
To make it work, the query fetching lists needed password
added to the results.
app/graphql/index.graphql
results {
id
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.
app/views/pages/index.liquid
-<div x-data="{ open: false }">
+<div x-data="{ open: false, protected: false }" class="p-8 bg-gray-200 rounded-lg">
<button
@click.prevent="open = !open"
x-show="open !== true"
@@ -24,8 +33,38 @@
action="/list/create"
method="POST"
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.
app/views/pages/list/show.liquid
{% 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 %}
Explanation:
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.
Note
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.
<p>
Wrong password. Access denied.
</p>
<div class="p-8 bg-gray-200 rounded-lg">
<form action="/list/show/{{ list.id }}">
<fieldset>
<input type="text" name="password" value="{{ context.params.password }}" placeholder="Password">
<button>Submit</button>
</fieldset>
</form>
</div>
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).
app/emails/created_protected_list.liquid
---
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>
<p>
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>
</p>
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.
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.
app/graphql/emails/created_protected_list.graphql
mutation notify($data: HashObject) {
email_send(
template: { name: "created_protected_list" }
data: $data
) {
errors { message }
is_scheduled_to_send
}
}
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.
app/views/pages/list/create.liquid
{% 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).
app/views/pages/list/show.liquid
{% 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 %}
Explanation:
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.
Summary
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.