🚀
Creating a web app from start to finish
Follow a step by step guide to creating a reminders web application using the Petal Pro boilerplate. We will cover everything from setup to deploying to production.

Our web app "Remindly"
Note: This tutorial was written using Petal Pro v1.5.2.
For this tutorial you will need:
- PostgreSQL running: Postgres.app works well for Mac or we have a docker-compose.yaml file that will run Postgres for you
- For deployment (optional) you will need:
- An Amazon Web Services account with Simple Email Service (SES) for emails (you can replace with another service if you like - it just won't be covered in this guide)
Head over to the projects page and create a new project, then download v1.5.2. You can unzip it in your terminal with the command
unzip petal_pro_1.5.2.zip
.Once it's unzipped we can go ahead and rename the folder to whatever we want to call it. In our case, we're going to call it "remindly".
Terminal
mv petal_pro_1.5.2 remindly
While this changes the folder name, we still need to change the name of all the files and module names (eg.
petal_pro_web.ex
should be remindly_web.ex
, etc). We have created a library to do this called rename_project.
rename_project is a library to help you rename your project.
terminal
mix rename PetalPro Remindly
Note: You may need to run
mix deps.get
first.Let's get the server running so we can see Petal Pro in action. Simply run in your terminal:
mix setup
mix phx.server
And now navigate to
http://localhost:4000
in your browser to see the dummy landing page:
The Petal Pro dummy landing page
Feel free to have a look around the website.
The
mix setup
command helpfully ran our seeds.ex
file, which if you open you will see this code:alias Remindly.Accounts.{User, UserToken, UserTOTP, UserSeeder}
alias Remindly.Logs.Log
alias Remindly.Orgs.OrgSeeder
alias Remindly.Orgs.{Org, Membership, Invitation}
if Mix.env() == :dev do
Remindly.Repo.delete_all(Log)
Remindly.Repo.delete_all(UserTOTP)
Remindly.Repo.delete_all(Invitation)
Remindly.Repo.delete_all(Membership)
Remindly.Repo.delete_all(Org)
Remindly.Repo.delete_all(UserToken)
Remindly.Repo.delete_all(User)
admin = UserSeeder.admin()
normal_user =
UserSeeder.normal_user(%{
email: "[email protected]",
name: "Sarah Cunningham",
password: "password",
confirmed_at: Timex.now() |> Timex.to_naive_datetime()
})
org = OrgSeeder.random_org(admin)
Remindly.Orgs.create_invitation(org, %{email: normal_user.email})
UserSeeder.random_users(20)
end
The
UserSeeder.admin()
command has created an admin for us. You can sign in as this admin with the details below: Email: [email protected]
Password: password
Run
mix test
to run the tests. They should be all green.Petal Pro also comes with some end-to-end (E2E) tests (see
signup_test.exs
). In order for E2E tests to work, you will need to install Chromedriver, which will run the tests like you were clicking things in a browser. On a Mac you can install it with:
brew install --cask chromedriver
Then to run the E2E tests run
mix test --only feature
. Or you could use our alias mix wallaby
(see aliases defined in mix.exs
).On Mac, you may get the error:
Error: “chromedriver” cannot be opened because the developer cannot be verified. Unable to launch the chrome browser
If this happens, you can run this command:
xattr -d com.apple.quarantine $(which chromedriver)
Obviously, the Petal branding will need to be replaced by your own brand. If you do a global search for "SETUP_TODO" you will find the code you need to modify.

Global search in VSCode for "SETUP_TODO"
The first mention you can ignore. The second match is in our
config.exs
file. Here we can update our web apps details:config.exs
...
# SETUP_TODO - ensure these details are correct
# Option descriptions:
# app_name: This appears in your email layout and also your meta title tag
# support_email: In your transactional emails there is a "Contact us" email - this is what will appear there
# mailer_default_from_name: The "from" name for your transactional emails
# mailer_default_from_email: The "from" email for your transactional emails
# dark_logo_for_emails: Your logo must reference a URL for email client (not a relative path)
# light_logo_for_emails: Same as above
# seo_description: Will go in your meta description tag
# css_theme_default: Can be "light" or "dark" - if "dark" it will add the class "dark" to the <html> element by default
config :remindly,
app_name: "Remindly",
business_name: "Remindly Pty Ltd",
support_email: "[email protected]",
mailer_default_from_name: "Remindly",
mailer_default_from_email: "[email protected]",
logo_url_for_emails: "https://res.cloudinary.com/wickedsites/image/upload/v1646277219/petal_marketing/remindly_logo_tsvksl.png",
seo_description: "Reminder app",
twitter_url: "https://twitter.com/PetalFramework",
github_url: "https://github.com/petalframework",
discord_url: "https://discord.gg/exbwVbjAct"
...
The next SETUP_TODO is found in the file
dev.ex
, which is to regenerate and replace the provided secret_key_base with mix phx.gen.secret
The next SETUP_TODO is found in the file
core_components.ex
. # SETUP_TODO
# This module relies on the following images. Replace these images with your logos
# /priv/static/images/logo_dark.svg
# /priv/static/images/logo_light.svg
# /priv/static/images/logo_icon_dark.svg
# /priv/static/images/logo_icon_light.svg
# /priv/static/images/favicon.png
You don't need to change anything in this file, but rather it's asking you to update the logos with your own. For this tutorial, I went into Figma and just wrote "Remindly" in an interesting font. You can duplicate my Figma file and replace with your logo if you like.

Creating a logo in Figma
The first 4 logos should be SVG. You can select them and export as SVG:

The rest can all be exported PNG.
Once you've exported them all you can drag them into your project:

Import your new logo files
Note that in case you missed it - since emails can be opened in all kinds of email clients, the logo must be hosted online somewhere.
I recommend uploading your logo in png format to some kind of image hosting you like (at least until you have a production server hosting the images for you). We personally like Cloudinary so let's upload our logo and then paste the URL into our config file. After you've done this, you can just paste the link in and your emails should reflect your new logo. We'll also discuss this later in the "Reminder emails" section of this tutorial.

Host your email logo somewhere and paste the link into logo_url_for_emails in the config.exs file
Now restart your server, reload your browser and check out your new logo!

See the new logo up the top left?
The other SETUP_TODO's are self-explanatory - they involve adding your license and privacy policy content as most SAAS applications will require them. You can add this content in pure markdown and we've provided some dummy content as an example to get you started. It's not really needed until you go live to production so you can put it off for now.
Petal Components relies heavily on mapping colors to different types, such as "primary", "secondary", "success", etc. This mapping is done in the
tailwind.config.js
file. By default, primary is blue and secondary is pink:
tailwind.config.js
const colors = require("tailwindcss/colors");
const plugin = require("tailwindcss/plugin");
module.exports = {
content: [
"../lib/*_web.ex",
"../lib/*_web/**/*.*ex",
"./js/**/*.js",
"../../../deps/petal_components/**/*.*ex",
"../../petal_framework/**/*.*ex",
],
darkMode: "class",
theme: {
extend: {
colors: {
primary: colors.blue,
secondary: colors.pink,
success: colors.green,
danger: colors.red,
warning: colors.yellow,
info: colors.sky,
gray: colors.slate,
},
},
},
plugins: [
...

Buttons in Petal Components - the first two colors are up to you
You can pick any two colors from Taildwind's comprehensive list. Or if you want to go custom, you can try out this site.
In our case, we'll go for the colors "amber" and "slate":
tailwind.config.js
...
theme: {
extend: {
colors: {
primary: colors.amber,
secondary: colors.slate,
...
...
If you refresh your landing page, you'll notice the color change.

Now we have our own logo plus some new primary and secondary colors
The landing page consists of content from two core files:
lib/remindly_web/components/landing_page.ex
- a set of components like<.hero>
,<.features>
,<.header>
etc. The end goal of Petal Pro is to have a comprehensive set of landing page components that can be used as building blocks for your landing page. Ideally, you won't modify these too much but will use them just like Petal Components. For now, you can modify them as you wish./lib/remindly_web/controllers/page_html/landing_page.html.heex
- a template file that uses the components fromlanding_page.ex
To keep this tutorial moving quickly, we won't do much design work on the landing page - if you open up
landing_page.html.heex
we can strip it down to merely a hero component:landing_page.html.heex
<.layout type="public" current_user={assigns[:current_user]} current_page={:landing}>
<LandingPage.hero
image_src={~p"/images/landing_page/hero.svg"}
logo_cloud_title={gettext("Trusted by brands all over the world")}
>
<:title>
<span><%= gettext("Always be") %></span>
<span class="text-primary-600">
<%= gettext("reminded") %>
</span>
<span><%= gettext("to do things") %>.</span>
</:title>
<:action_buttons>
<.button
label={gettext("Get started")}
link_type="a"
color="primary"
to={~p"/auth/signup"}
size="lg"
/>
<.button
label={gettext("Guide")}
link_type="a"
color="white"
to="https://docs.petal.build"
size="lg"
/>
</:action_buttons>
<:description>
<%= gettext(
"To be a functioning adult you need to be reminded to do adult-like tasks, like getting a hair cut or going to the dentist."
) %>
</:description>
</LandingPage.hero>
</.layout>
<LandingPage.load_js_animations />
Now refresh your landing page and it should be a lot more simple. It is up to you how detailed you want to be with your landing page.

Our rather basic landing page
To complement the landing page it would be nice to have an "About" page. This is a good chance to show off Petal Pro's "Page Builder" functionality.
Usually, when you want to create a new page, you would have to create a new template file or live view file. Then you would create a route and point it either to the template's corresponding controller action or directly to the live view which all takes valuable minutes. To skip these steps, we made it so that if you navigate to a path with no route on it then it will show what we call "The Page Builder" 👏.
The Page Builder is simply a form that helps you construct a page at that route. Submitting the form will result in the route being automatically added to the
router.ex
file and either a template or live view file is created for you.NOTE: In this version the Page Builder is unfortunately broken. Until we push a patch up you can download and unzip this file and put it in your
priv
dir in your application. Then it should work properly.Let's try it out. In your browser go to "localhost:4000/about". This page won't exist so the "Page Builder" will load up. Here is where we can input the details of our new page.
We'll make a change before submitting: for "Page type", change to "Traditional static view" - we won't be needing any live view functionality.

After submitting the form the page should refresh itself because the
about.html.heex
template file has been added to the file system. You may have to refresh, however. The "Page Builder" has done 3 things for us:- 1.Added a new "/about" route in
router.ex
- 2.Added a new
about/2
function inpage_controller.ex
- 3.Added a new template
/remindly_web/controllers/page_html/about.html.heex

About page done!
Now it should look more like our landing page. Obviously, it looks unfinished, but we've shown how you can use "Page Builder" to speed up your development for these stray pages.
How does the Page Builder know where to insert the route? If you look inside the
router.ex
file, you will see some comments spread out through the file that looks like this: page_builder:live:public
. Any comment like this is like a target for the Page Builder to add routes beneath it. When you load the Page Builder it will scan the router for these strings starting with "page_builder:" and then allow you to select one of them from a list. Whichever one you select, the route will be added AFTER that line.You can add more route targets that Page Builder will pick up - just write a comment in the format
page_builder:<type>:<name>
. The type
must be live
or static
. The name
can be whatever you like, eg: pagebuilder:live:my_special_routes
.Let's modify our menu items to include only this about page. The public menu can be found in the file
menus.ex
in a function public_menu_items/1
. Modify it to this:menus.ex
def public_menu_items(_current_user \\ nil),
do: [
%{label: gettext("About"), path: "/about"}
]
And now there should be a menu item in the navbar:

Our nav will update automatically after changing
public_menu_items
Now that the public-facing side of things is looking good enough, we can work on the app itself.
When a user signs in we want them to see a list of their reminders that they can check off or delete, with a button to create new ones. Normally this would be a job for a Phoenix generator - luckily Petal Pro has its own version of the
mix phx.gen.live
generator - mix petal.gen.live
. Our generator produces the same files as the Phoenix one, but the resulting HTML will use Tailwind and Petal Components so we don't have to worry about styling. Let's run the generator to create the following schema:
Field | Type |
---|---|
label | :string |
due_date | :date |
is_done | :boolean |
user_id | :integer (foreign key) |
mix petal.gen.live Reminders Reminder reminders label:string due_date:date is_done:boolean user_id:references:users
The command will output the routes needed to make the new pages work. Highlight them and just copy them to your clipboard for now.
Let's run the migration the generator just created:
mix ecto.migrate
Now we need to add these routes to the router. We can use the Page Builder tags to help us know where to put them. Do a search for "page_builder:live:authenticated" to see live routes that require a logged in user.
Let's paste the routes we copied from the generator command below that line.

Paste the routes from the generator into router.ex
Petal Pro defaults to scoping authenticated routes within the “/app” namespace. To continue this naming convention we need to edit the routes within the generated code. To do this you can do a "find and replace all" within the
reminder_live
folder.

Replace
~p"/reminders
with ~p"/app/reminders
.
Since it's in a protected area, we'll have to sign in before reaching this new page. Click the sign-in button in the top right dropdown:

The sign in button
Sign in as admin:
- Email:
[email protected]
- Password:
password
Upon your first sign in you will be greeted with an onboarding screen.

Every new user is shown the onboarding screen.
Showing this screen is determined by the user field
user.is_onboarded.
All new users have this defaulted to
false
, and thus will see this screen thanks to the OnboardingPlug
that redirects to this page if current_user.is_onboarded == false
. Over time you might add more onboarding fields like "Where did you hear about us?" or "How many people are in your company?".
Onboarding can be switched off by removing the
OnboardingPlug
from all pipelines in the router. There is further explanation for this in onboarding_plug.ex
Just hit submit to get through the onboarding and you will then be shown a dashboard that we will ignore.
Instead, manually navigate to "http://localhost:4000/app/reminders" to see the newly created CRUD functionality.

Our reminders CRUD page.
You'll notice there is no layout. This is because we have to add that ourselves. We'll do that in a little bit. Firstly, let's get it running properly.
One issue with the generator is that it doesn't handle foreign keys very well. Currently, the reminders page shows reminders from ALL users. In fact, the
reminder.user_id
is not even being set when creating a new reminder, so they're not really owned by anyone - every user who signs in will see them. We need to make it so everything is scoped to the currently signed-in user:- the index page table should only show the current users' reminders
- when you create a new reminder, it should set the user_id to the current user's id
- the show page should not be allowed to see other user's reminders
Firstly, there are a couple of problems with the schema file:

Generators aren't perfect
Let's fix those up:
reminder.ex
defmodule Remindly.Reminders.Reminder do
use Ecto.Schema
import Ecto.Changeset
schema "reminders" do
field :due_date, :date
field :is_done, :boolean, default: false
field :label, :string
belongs_to :user, Remindly.Accounts.User
timestamps()
end
@doc false
def changeset(reminder, attrs) do
reminder
|> cast(attrs, [:label, :due_date, :is_done, :user_id])
|> validate_required([:label, :due_date, :is_done, :user_id])
end
end
Note that we've added
:user_id
to the validate_required
function too, as we always want a reminder to belong to someone. The reminder schema now knows it belongs to a user, but we still have to let the user schema know that it "has_many" reminders. Let's add that to the user schema file:
user.ex
defmodule Remindly.Accounts.User do
...
schema "users" do
# ...other fields omitted for brevity
many_to_many :orgs, Org, join_through: "orgs_memberships", unique: true
# Add the line below:
has_many :reminders, Remindly.Reminders.Reminder
timestamps()
end
...
end
Now go to
index.ex
in your reminder_live
folder and let's modify the `assign_reminders` function:index.ex
defmodule RemindlyWeb.ReminderLive.Index do
...
defp assign_reminders(socket, params) do
starting_query = Ecto.assoc(socket.assigns.current_user, :reminders)
{reminders, meta} = DataTable.search(starting_query, params, @data_table_opts)
assign(socket, reminders: reminders, meta: meta)
end
...
end
Notice how the
starting_query
has changed? It now doesn't just fetch all reminders, but only the ones that belong to the signed-in user.Great, the association is now complete. Now let's make it so that when you create a new reminder, the
reminder.user_id
gets populated with the signed-in user's id
.The code for creating the reminder is located in
lib/remindly_web/live/reminder_live/form_component.ex
:form_component.ex
defmodule RemindlyWeb.ReminderLive.FormComponent do
...
defp save_reminder(socket, :new, reminder_params) do
case Reminders.create_reminder(reminder_params) do
{:ok, _reminder} ->
{:noreply,
socket
|> put_flash(:info, "Reminder created successfully")
|> push_navigate(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
...
end
Let's modify this to ensure the
:user_id
is set in the params:defmodule RemindlyWeb.ReminderLive.FormComponent do
...
defp save_reminder(socket, :new, reminder_params) do
reminder_params = Map.put(reminder_params, "user_id", socket.assigns.current_user.id)
case Reminders.create_reminder(reminder_params) do
{:ok, _reminder} ->
{:noreply,
socket
|> put_flash(:info, "Reminder created successfully")
|> push_navigate(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
...
end
Now when you try and create a new reminder, unfortunately, you get an error:

Error upon creating a new reminder
The
current_user
isn't in socket.assigns
. This can be confusing because it is usually in the socket's assigns thanks to Petal Pro setting it in user_on_mount_hooks.ex
. The reason it isn't in this case is that
form_component.ex
is a live_component
, not a live_view
. Thus it has its own state and it's up to us to pass any assigns
into it. We can see in index.html.heex
the assigns being passed into it:index.html.heex
<%= if @live_action in [:new, :edit] do %>
<.modal title={@page_title}>
<.live_component
module={RemindlyWeb.ReminderLive.FormComponent}
id={@reminder.id || :new}
action={@live_action}
reminder={@reminder}
return_to={@return_to}
/>
</.modal>
<% end %>
So all we need to do is pass in the current_user to this
live_component
function:index.html.heex
<%= if @live_action in [:new, :edit] do %>
<.modal title={@page_title}>
<.live_component
module={RemindlyWeb.ReminderLive.FormComponent}
id={@reminder.id || :new}
action={@live_action}
reminder={@reminder}
return_to={@return_to}
current_user={@current_user}
/>
</.modal>
<% end %>
And now creating a new reminder works correctly 🎊.
The next problem is that the date picker starts too far in the past - ideally, it'll show today's date. So let's add a default date for the form. This also will be done in the
RemindlyWeb.ReminderLive.Index
live view (index.ex
). We can modify the existing function, so it looks like this:reminder_live/index.ex
defmodule RemindlyWeb.ReminderLive.Index do
...
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Reminder")
|> assign(:reminder, %Reminder{due_date: Timex.now() |> Timex.to_date()})
|> assign(:return_to, current_index_path(socket))
end
...
end

The due date is now set to today.
We use the Timex library (included in Petal Pro) to get today's date. Now the default is set correctly when creating new reminders (although since we're using UTC time, it may be 1 day off depending on where you live, but good enough for this tutorial).
The "Is done" column with true or false is a bit ugly.
Let's move it to the front column and make it a checkbox. Working in the file
index.html.heex
, let's begin by rearranging the headers:index.html.heex
```phoenix-heex
<.data_table :if={@index_params} meta={@meta} items={@reminders}>
<:if_empty>No reminders found</:if_empty>
<:col field={:is_done} sortable label="Done?" />
<:col field={:label} sortable />
<:col field={:due_date} sortable />
```

The Done? column has moved across
Next, we'll add an icon in the first cell that shows whether the task is done or not.
index.html.heex
```phoenix-heex
<.data_table :if={@index_params} meta={@meta} items={@reminders}>
<:if_empty>No reminders found</:if_empty>
<:col :let={reminder} field={:is_done} sortable label="Done?">
<div class="cursor-pointer" phx-click="toggle_reminder" phx-value-id={reminder.id}>
<.icon
name={if reminder.is_done, do: :check, else: :x_mark}
class="w-5 h-5"
/>
</div>
</:col>
<:col field={:label} sortable />
...
```
Here we use
:let={reminder}
to expose the reminder
variable to the inside of the :col
slot. If the user clicks the icon, it calls the toggle_reminder
event. Let's create the event handler for "toggle_reminder". Add this event handler to the
index.ex
file:index.ex
@impl true
def handle_event("toggle_reminder", %{"id" => reminder_id}, socket) do
reminder = Reminders.get_reminder!(reminder_id)
case Reminders.update_reminder(reminder, %{is_done: !reminder.is_done}) do
{:ok, _reminder} ->
{:noreply, assign_reminders(socket, socket.assigns.index_params)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Something went wrong.")}
end
end
Now our checkbox is in sync. Live view works so fast that you can barely notice it's not a Single Page App. However, this won't always be the case once if someone is a long way from the server and has some latency.
With LiveView you can simulate latency. If you look in
app.js
you'll find a comment with a command to run the in the browser console. This will add 1 second of latency: liveSocket.enableLatencySim(1000)
. Run this in your browser console to see the lag. LiveView adds classes when it's performing some kind of communication with the server. Phoenix helpfully adds a plugin to your
tailwind.config.js
file that allows you to target these classes:
Let's make use of this and add a spinner while waiting to hear back from the server.
index.html.heex
<:col :let={reminder} field={:is_done} sortable label="Done?">
<div class="cursor-pointer" phx-click="toggle_reminder" phx-value-id={reminder.id}>
<.icon
name={if reminder.is_done, do: :check, else: :x_mark}
class="w-5 h-5 phx-click-loading:hidden"
/>
<.spinner class="hidden phx-click-loading:block" />
</div>
</:col>
With that working correctly, we can disable the latency in the browser console:
liveSocket.disableLatencySim()
.Now that our table is working nicely, let's add the layout back in.
index.html.heex
<.layout current_page={:reminders} current_user={@current_user} type="sidebar">
<!-- Your existing code -->
</.layout>
And do the same with
show.html.heex
. The problem now is the sidebar menu doesn't show a link to the reminders page. Let's change that.In Petal Pro we try to keep our menu items all following the same data structure (
name
, label
, path
, and icon
, which refers to a Heroicon). Menu items can be constructed on the fly, but if it's commonly used we can store them in menus.ex
. This file is like a small database for menu items. It also can omit menu items if a user doesn't have the right privileges (eg. admin menu items). Each menu item has a function that looks like this:
def get_link(:dashboard, _current_user) do
%{
name: :dashboard,
label: gettext("Dashboard"),
path: ~p"/app",
icon: :template
}
end
You can browse Heroicons and when you've found the one you like, just write the atom version of it. For example, the icon "bookmark-alt" would be
:bookmark_alt
.From these function definitions, we can construct menus. There are two core menus - the main menu, which appears on this side panel, and also the user menu, which is in this dropdown.

Since we want to modify the sidebar, we want to change the main menu for a signed-in user. Let's replace
:dashboard
and :orgs
with :reminders
: menus.ex
defmodule RemindlyWeb.Menus do
...
# Signed in main menu
def main_menu_items(current_user),
do:
build_menu(
[
:reminders
],
current_user
)
...
end
Also, make sure you rename
:dashboard
with :reminders
in the user menu.menus.ex
defmodule RemindlyWeb.Menus do
...
# Signed in user menu
def user_menu_items(current_user),
do:
build_menu(
[
:reminders,
:settings,
:admin,
:dev,
:sign_out
],
current_user
)
...
end
However, this won't work yet as we still need to write a definition function for
:reminders
. Scroll down the file until you see the get_link/2
function for :dashboard
. Let's overwrite this function and replace it with our reminders menu item:menus.ex
defmodule RemindlyWeb.Menus do
...
# Replace the get_link(:dashboard, ...) function with this:
def get_link(:reminders = name, _current_user) do
%{
name: name,
label: gettext("Reminders"),
path: ~p"/app",
icon: :clock
}
end
...
end
Next, we want this new route to be our kind of "home" route for signed-in users (eg. redirect here after being signed in, or when you click the logo in a layout). To set this, go to
helpers.ex
and look for the home_path/2
function:helpers.ex
def home_path(nil), do: "/"
def home_path(_current_user), do: ~p"/app"
Here we can simply add the path of the new menu item:
helpers.ex
def home_path(nil), do: "/"
def home_path(_current_user), do: ~p"/app/reminders"
Now refresh the page and we can see the sidebar has been updated!

We've found that at some point in building a web application, you need to implement some kind of logging functionality. You or your client will want to see some stats on how your users are using your web application.
Petal Pro puts logging at the forefront, and encourages you to log as much as you can. By default, it will store logs in your Postgres database, but there's no reason why you can't shoot them off to a 3rd party provider.
To showcase Petal Pro logging, we'll create a log every time a user creates a reminder and toggles it to "done". If you look in the file
log.ex
, you will see we have a list of actions that can be logged. Let's add our new log actions:log.ex
defmodule Remindly.Logs.Log do
...
@action_options [
# ... other logs omitted. Add the two below:
"create_reminder",
"complete_reminder"
]
...
Now we can do the actual logging. Go to
form_component.ex
and add a log in the create action.form_component.ex
defp save_reminder(socket, :new, reminder_params) do
reminder_params = Map.put(reminder_params, "user_id", socket.assigns.current_user.id)
case Reminders.create_reminder(reminder_params) do
{:ok, reminder} ->
Remindly.Logs.log("create_reminder", %{
user: socket.assigns.current_user,
metadata: %{
reminder_id: reminder.id,
reminder_label: reminder.label
}
})
{:noreply,
socket
|> put_flash(:info, "Reminder created successfully")
|> push_navigate(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
You will see we're adding some metadata. It is up to you whether or not for new tables like "reminders" you create a migration and add a foreign key to the logs table (
log.reminder_id
). To keep it simple we can make use of a JSONB column on the logs table called metadata (JSONB is better than a JSON column as it allows for faster searching and even indexing). In this column, we can store any kind of map we want. We'll store the ID and label of the reminder here so we can track it if need be.Now create a new reminder and we'll check that our logs have been updated. Logs are in the admin section - try clicking the user menu (your avatar up the top right), and select "Admin".

Then click logs on the sidebar menu. Here you should see a new log for creating a reminder.

A log will appear when you create a reminder.
Let's do another log for when we complete a reminder. We'll need to modify our
toggle_reminder
function in index.ex
.index.ex
@impl true
def handle_event("toggle_reminder", %{"id" => reminder_id}, socket) do
reminder = Reminders.get_reminder!(reminder_id)
case Reminders.update_reminder(reminder, %{is_done: !reminder.is_done}) do
{:ok, reminder} ->
if reminder.is_done do
Remindly.Logs.log("complete_reminder", %{
user: socket.assigns.current_user,
metadata: %{
reminder_id: reminder.id,
reminder_label: reminder.label
}
})
end
{:noreply, assign_reminders(socket, socket.assigns.index_params)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Something went wrong.")}
end
end
Now, when we toggle a reminder to "done", a new log will appear.
You might have noticed a toggle for "Live logs" on the logs screen. If you turn this on, you will actually get a live feed of what's happening in your web app. You can try it out by opening two browser windows side by side: one on logs with "Live logs" checked, and one on the reminders screen. If you toggle the reminder on and off, you will see the logs appearing on the admin screen in real-time:

Real time logs thanks to PubSub
Note that if it isn't working you might have to restart your server.
Next, we need to implement an email reminder that gets sent the day a reminder is overdue. If we head to "Dev", and then click on "Email templates" we can see a list of our transactional emails.