πŸš€
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.1.1.

Getting up and running

What you need

For this tutorial you will need:

Download

Head to the downloads page and download v1.1.1. You can unzip it in your terminal with the command unzip petal_pro_1.1.1.zip.

Renaming your project

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
1
mv petal_pro_1.1.1 remindly
Copied!
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). Petal Pro allows you to easily rename your project with a bash script. Open up rename_phoenix_project.sh and you'll see some instructions:
rename_phoenix_project.
1
...
2
​
3
# 1. Ensure you've checked your files into a git repo (`git init .`, `git add -A`, `git commit -m 'first'`)
4
# 2. Modify these two values to your new app name
5
NEW_NAME="YourAppName"
6
NEW_OTP="your_app_name"
7
# 3. LINUX USERS ONLY - Scroll down and comment out lines 25/25 and uncomment lines 29/30
8
# 4. Execute the script in terminal: `sh rename_phoenix_project.sh`
9
​
10
...
Copied!
Let's follow these instructions in our terminal:
Terminal
1
git init .
2
git add -A
3
git commit -m 'first'
Copied!
Now, let's put our desired name ("remindly") into the script:
rename_phoenix_project.sh
1
...
2
​
3
# 1. Ensure you've checked your files into a git repo (`git init .`, `git add -A`, `git commit -m 'first'`)
4
# 2. Modify these two values to your new app name
5
NEW_NAME="Remindly"
6
NEW_OTP="remindly"
7
# 3. LINUX USERS ONLY - Scroll down and comment out lines 25/25 and uncomment lines 29/30
8
# 4. Execute the script in terminal: `sh rename_phoenix_project.sh`
9
​
10
...
Copied!
And finally, run the script:
Terminal
1
sh rename_phoenix_project.sh
Copied!
If it produces no output, then you know it's run correctly. You can verify by looking at your git changes - there should be a lot of changes and file renaming.
For Linux users: as per the instructions you will need to do an additional task - simply follow the instructions in the script
If you like you can commit this renaming to git so you have a fresh starting point.
1
git add -A
2
git commit -m "Renamed to remindly"
Copied!

Test run

Let's get the server running so we can see Petal Pro in action. Simply run in your terminal:
1
mix setup
2
mix phx.server
Copied!
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 mix setup command helpfully ran our seeds.ex file, which if you open you will see this code:
1
if Mix.env() == :dev do
2
PetalPro.Repo.delete_all(Log)
3
PetalPro.Repo.delete_all(UserToken)
4
PetalPro.Repo.delete_all(User)
5
​
6
UserSeeder.admin()
7
UserSeeder.random_users(20)
8
end
Copied!
The UserSeeder.admin() command has created an admin for us. You can login as this admin with "[email protected]" and the password "password". We don't explain too much here, as you'll touch different parts of Petal Pro throughout different parts of this tutorial.

Adding your brand

Configuration

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.
Search for SETUP_TODO to see what you need to change
The first two mentions you can ignore; one is in the README which is similar to this tutorial. The other is in rename_phoenix_project.sh, which we've already done. The 3rd match is in our config.exs file. In here we can update our web apps details:
config.exs
1
...
2
​
3
# SETUP_TODO - ensure these details are correct
4
# Option descriptions:
5
# app_name: This appears in your email layout and also your meta title tag
6
# support_email: In your transactional emails there is a "Contact us" email - this is what will appear there
7
# mailer_default_from_name: The "from" name for your transactional emails
8
# mailer_default_from_email: The "from" email for your transactional emails
9
# dark_logo_for_emails: Your logo must reference a URL for email client (not a relative path)
10
# light_logo_for_emails: Same as above
11
# seo_description: Will go in your meta description tag
12
# css_theme_default: Can be "light" or "dark" - if "dark" it will add the class "dark" to the <html> element by default
13
config :remindly,
14
app_name: "Remindly",
15
business_name: "Remindly Pty Ltd",
16
support_email: "[email protected]",
17
mailer_default_from_name: "Remindly",
18
mailer_default_from_email: "[email protected]",
19
logo_url_for_emails: "https://res.cloudinary.com/wickedsites/image/upload/v1646277219/petal_marketing/remindly_logo_tsvksl.png",
20
seo_description: "Reminder app",
21
css_theme_default: "dark"
22
23
...
Copied!
The next SETUP_TODO is found in the file brand.ex.
1
# SETUP_TODO
2
# This module relies on the following images. Replace these images with your logos
3
# /priv/static/images/logo_dark.svg
4
# /priv/static/images/logo_light.svg
5
# /priv/static/images/logo_icon_dark.svg
6
# /priv/static/images/logo_icon_light.svg
7
# /priv/static/images/favicon.png
Copied!
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
Once you've exported them all you can drag them into your project:
Import your new logo files
Now reload your browser and check out your new logo.

Tailwind primary and secondary colors

Petal Components relies heavily on having a primary and secondary brand color set in your tailwind.config.js file.
By default, primary is blue and secondary is pink:
tailwind.config.js
1
const colors = require("tailwindcss/colors");
2
​
3
module.exports = {
4
content: [
5
"../lib/*_web/**/*.*ex",
6
"./js/**/*.js",
7
"../deps/petal_components/**/*.*ex",
8
],
9
darkMode: "class",
10
theme: {
11
extend: {
12
colors: {
13
primary: colors.blue,
14
secondary: colors.pink,
15
},
16
},
17
},
18
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
19
};
Copied!
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 super custom, you try out TailwindInk.
In our case, we'll go for the colors "amber" and "slate":
tailwind.config.js
1
const colors = require("tailwindcss/colors");
2
​
3
module.exports = {
4
content: [
5
"../lib/*_web/**/*.*ex",
6
"./js/**/*.js",
7
"../deps/petal_components/**/*.*ex",
8
],
9
darkMode: "class",
10
theme: {
11
extend: {
12
colors: {
13
primary: colors.amber,
14
secondary: colors.slate,
15
},
16
},
17
},
18
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
19
};
Copied!
If you refresh your landing page, you'll notice the color change.

Public facing pages

Landing page

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 like building blocks for your landing page. Ideally, you won't modify these too much but will use them just like Petal Components
  • /lib/remindly_web/templates/page/landing_page.html.heex - a template file that uses the components from landing_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
1
<LandingPage.css />
2
​
3
<% main_menu_items = [] %>
4
​
5
<LandingPage.header
6
current_user={assigns[:current_user]}
7
conn={@conn}
8
current_page={:landing}
9
main_menu_items={main_menu_items}
10
user_menu_items={user_menu_items(@current_user)}
11
/>
12
​
13
<LandingPage.hero image_src={Routes.static_path(@conn, "/images/landing_page/hero.svg")}>
14
<:title>
15
<span><%= gettext("Always be") %></span>
16
<span class="text-primary-600">
17
<%= gettext("reminded") %>
18
</span>
19
<span><%= gettext("to do things") %>.</span>
20
</:title>
21
​
22
<:action_buttons>
23
<.button
24
label={gettext("Get started")}
25
link_type="a"
26
color="primary"
27
to={Routes.user_registration_path(@conn, :new)}
28
size="lg"
29
/>
30
</:action_buttons>
31
​
32
<:description>
33
<%= 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.") %>
34
</:description>
35
</LandingPage.hero>
36
​
37
<LandingPage.javascript />
38
​
Copied!
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

About 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. To skip these steps, we made it so 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.
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
Using the Page Builder to create a new page
After submitting the form the page should refresh itself because the about.html.heex template file has been added on the file system. The "Page Builder" has done 3 things for us:
  1. 1.
    Added a new "/about" route in router.ex
  2. 2.
    Added a new about/2 function in page_controller.ex
  3. 3.
    Added a new template about.html.heex
Looking at the page, you can see the layout is a little different to our landing page - this is using the "Stacked" layout. Petal Pro's two layouts "Stacked" and "Sidebar" are designed for when a user is logged in - an "App Layout" if you will. Whereas, this "About" page is more of a public, marketing page. So let's change it to use the same layout as our landing page:
1
<LandingPage.css />
2
​
3
<% main_menu_items = [
4
%{label: gettext("About"), path: "/about"},
5
] %>
6
​
7
<LandingPage.header
8
current_user={assigns[:current_user]}
9
conn={@conn}
10
current_page={:landing}
11
main_menu_items={main_menu_items}
12
user_menu_items={user_menu_items(@current_user)}
13
/>
14
​
15
<.container max_width="xl" class="my-20">
16
<.h2>About</.h2>
17
<.p>
18
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Tenetur facilis ratione temporibus sunt odit ad soluta doloribus natus possimus quasi, quidem, illum aut aspernatur vel sint consequatur libero dolore velit.
19
</.p>
20
</.container>
21
​
Copied!
Our "About" page
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 look 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.
Back on the landing page, let's add a menu item up the top for the "About" page:
landing_page.html.heex
1
<LandingPage.css />
2
​
3
<% main_menu_items = [
4
%{label: gettext("About"), path: "/about"},
5
] %>
6
​
7
...
Copied!
And now there should be a menu item in the navbar:
Our nav will update automatically after changing main_menu_items

The reminders page

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 it's 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.

Using petal.gen.live

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)
1
mix petal.gen.live Reminders Reminder reminders label:string due_date:date is_done:boolean user_id:references:users
Copied!
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:protected" to see live routes that are protected, which means only authenticated users can access this area. Let's paste in the routes here underneath that line.
Pasting the routes into router.ex
Now go to "http://localhost:4000/reminders" to see the newly created CRUD functionality.
The result of mix petal.gen.live ...

Assigning reminders to the current user

One issue with the generator is that it doesn't handle foreign keys very well. Currently the reminders page shows ALL reminders from all users. In fact, the user_id is not even being set when create 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 current logged 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 users reminders
Firstly, there's a couple of problems with the schema file:
Generators aren't perfect
Let's fix those up:
reminder.ex
1
defmodule Remindly.Reminders.Reminder do
2
use Ecto.Schema
3
import Ecto.Changeset
4
​
5
schema "reminders" do
6
field :due_date, :date
7
field :is_done, :boolean, default: false
8
field :label, :string
9
​
10
belongs_to :user, Remindly.Accounts.User
11
​
12
timestamps()
13
end
14
​
15
@doc false
16
def changeset(reminder, attrs) do
17
reminder
18
|> cast(attrs, [:label, :due_date, :is_done, :user_id])
19
|> validate_required([:label, :due_date, :is_done, :user_id])
20
end
21
end
Copied!
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 in to the user schema file:
user.ex
1
defmodule Remindly.Accounts.User do
2
...
3
​
4
schema "users" do
5
field :name, :string
6
field :email, :string
7
# ...other fields omitted for brevity
8
9
has_many :reminders, Remindly.Reminders.Reminder
10
​
11
timestamps()
12
end
13
14
...
15
end
Copied!
Great, the association is now complete. Now let's make it so when you create a new reminder, the user_id gets populated with the logged 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
1
defmodule RemindlyWeb.ReminderLive.FormComponent do
2
...
3
​
4
defp save_reminder(socket, :new, reminder_params) do
5
case Reminders.create_reminder(reminder_params) do
6
{:ok, _reminder} ->
7
{:noreply,
8
socket
9
|> put_flash(:info, "Reminder created successfully")
10
|> push_redirect(to: socket.assigns.return_to)}
11
​
12
{:error, %Ecto.Changeset{} = changeset} ->
13
{:noreply, assign(socket, changeset: changeset)}
14
end
15
end
16
17
...
18
end
Copied!
Let's modify this to ensure the :user_id is set in the params:
1
defmodule RemindlyWeb.ReminderLive.FormComponent do
2
...
3
​
4
defp save_reminder(socket, :new, reminder_params) do
5
reminder_params = Map.put(reminder_params, "user_id", socket.assigns.current_user.id)
6
​
7
case Reminders.create_reminder(reminder_params) do
8
{:ok, _reminder} ->
9
{:noreply,
10
socket
11
|> put_flash(:info, "Reminder created successfully")
12
|> push_redirect(to: socket.assigns.return_to)}
13
​
14
{:error, %Ecto.Changeset{} = changeset} ->
15
{:noreply, assign(socket, changeset: changeset)}
16
end
17
end
18
19
...
20
end
Copied!
Now when you try and create a new reminder, 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. The reason it isn't is because form_component.ex is a live_component, not a live_view. Thus it has it's own state and it's up to us to pass the assigns into it. We can see in index.html.heex the assigns being passed into it:
The assigns of FormComponent are set here
So all we need to do is pass in the current_user to this live_component function:
index.html.heex
1
...
2
​
3
<%= if @live_action in [:new, :edit] do %>
4
<.modal title={"#{Atom.to_string(@live_action) |> String.capitalize()} Reminder"}>
5
<.live_component
6
module={RemindlyWeb.ReminderLive.FormComponent}
7
id={@reminder.id || :new}
8
action={@live_action}
9
reminder={@reminder}
10
return_to={Routes.reminder_index_path(@socket, :index)}
11
current_user={@current_user}
12
/>
13
</.modal>
14
<% end %>
15
​
16
...
Copied!
And now creating a new reminder works correctly 🎊.
The next problem I see is that the date picker starts too far in the past - ideally it'll show todays date. So let's add in a default date for the form. This also will be done in the RemindlyWeb.ReminderLive.Index live view:
1
defmodule RemindlyWeb.ReminderLive.Index do
2
...
3
4
defp apply_action(socket, :new, _params) do
5
socket
6
|> assign(:page_title, "New Reminder")
7
|> assign(:reminder, %Reminder{due_date: Timex.now() |> Timex.to_date()})
8
end
9
10
...
11
end
Copied!
The date now defaults to today
We use the Timex library (included in Petal Pro) to get todays date. Now the default is set correctly when creating new reminders (although since we're using UTC time, it maybe 1 day off depending on where you live, but good enough for this tutorial).

Adding a "Done" checkbox

The "Is done" column with true or false is a bit ugly.
Bad UX
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
1
<thead>
2
<tr>
3
<th>Done?</th>
4
<th>Label</th>
5
<th>Due date</th>
6
<th></th>
7
</tr>
8
</thead>
Copied!
Next, we'll rearrange the table cells and also add a checkbox in the first cell. Since we want it to submit every time we modify the checkbox, we'll wrap it in a form and add a phx-change event listener on it. So every time the form changes an event will be sent to the live view.
1
<%= for reminder <- @reminders do %>
2
<tr id={"reminder-#{reminder.id}"}>
3
<td>
4
<.form let={f} for={Reminders.change_reminder(reminder)} phx-change="toggle_reminder">
5
<%= hidden_input f, :id %>
6
<.checkbox form={f} field={:is_done} />
7
</.form>
8
</td>
9
<td><%= reminder.label %></td>
10
<td><%= reminder.due_date %></td>
11
<td class="text-right">
12
<.button color="white" variant="outline" size="xs" link_type="live_redirect" label="Show" to={Routes.reminder_show_path(@socket, :show, reminder)} />
13
<.button color="white" variant="outline" size="xs" link_type="live_patch" label="Edit" to={Routes.reminder_index_path(@socket, :edit, reminder)} />
14
<.button color="danger" variant="outline" link_type="button" size="xs" label="Delete" phx-click="delete" phx-value-id={reminder.id} data-confirm="Are you sure?" />
15
</td>
16
</tr>
17
<% end %>
Copied!
Now we just need to create the event handler for "toggle_reminder". Add this event handler underneath the event handler for "delete":
index.ex
1
@impl true
2
def handle_event("toggle_reminder", %{"reminder" => reminder_params}, socket) do
3
reminder = Reminders.get_reminder!(reminder_params["id"])
4
​
5
case Reminders.update_reminder(reminder, %{is_done: !reminder.is_done}) do
6
{:ok, reminder} ->
7
updated_reminders = Util.replace_object_in_list(socket.assigns.reminders, reminder)
8
{:noreply, assign(socket, reminders: updated_reminders)}
9
{:error, _changeset} ->
10
{:noreply, put_flash(socket, :error, "Something went wrong.")}
11
end
12
end
Copied!
The checkbox toggle is now working
Now our checkbox is in sync. Live view works so fast 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).
Is feels the same, but you can see the delay more clearly with the console open. LiveView adds classes when it's performing some kind of communication with the server. Petal Pro comes with some helper classes "show-if-loading" and "hide-if-loading" that we can make use of. Let's hide the checkbox button and show a spinner until LiveView has saved the update in the database:
index.html.heex
1
...
2
​
3
<.form let={f} for={get_changeset(reminder)} phx-change="toggle_reminder">
4
<%= hidden_input f, :id %>
5
<.checkbox form={f} field={:is_done} class="hide-if-loading" />
6
<.spinner size="xs" class="show-if-loading" />
7
</.form>
8
​
9
...
Copied!
Nice, although our spinner is still too big even with the smallest size selected. Let's override the sizing classes to make it smaller.
index.html.heex
1
...
2
​
3
<.form let={f} for={get_changeset(reminder)} phx-change="toggle_reminder">
4
<%= hidden_input f, :id %>
5
<.checkbox form={f} field={:is_done} class="hide-if-loading" />
6
<.spinner class="show-if-loading !w-4 !h-4" />
7
</.form>
8
​
9
...
Copied!
A loading indicator shows when live view is performing the "toggle_reminder" action
With that working correctly, we can disable the latency in the browser console: liveSocket.disableLatencySim().

Adding a menu item

In Petal Pro we try to keep our menu items in one file, called menus.ex. Each menu item has a function that looks like this:
1
def get_link(:dashboard, _current_user) do
2
%{
3
name: :dashboard,
4
label: gettext("Dashboard"),
5
path: Routes.live_path(Endpoint, RemindlyWeb.DashboardLive),
6
icon: :template
7
}
8
end
Copied!
The icon is passed directly Heroicons.Outline.render: <Heroicons.Outline.render icon={menu_item.icon} />. You can browse Heroicons and when you've found 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 logged in user. Let's replace :dashboard with :reminders:
menus.ex
1
defmodule RemindlyWeb.Menus do
2
...
3
4
# Signed in main menu
5
def main_menu_items(current_user),
6
do:
7
build_menu(
8
[
9
:reminders
10
],
11
current_user
12
)
13
14
...
15
end
Copied!
However, this won't work 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
1
defmodule RemindlyWeb.Menus do
2
...
3
4
# Replace the get_link(:dashboard, ...) function with this:
5
def get_link(:reminders, _current_user) do
6
%{
7
name: :reminders,
8
label: gettext("Reminders"),
9
path: Routes.reminder_index_path(Endpoint, :index),
10
icon: :clock
11
}
12
end
13
14
...
15
end
Copied!
Next we want this new route to be our kind of "home" route for logged 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
1
def home_path(nil), do: "/"
2
def home_path(current_user), do:
3
RemindlyWeb.Menus.get_link(:dashboard, current_user).path
Copied!
You can see we're looking for :dashboard menu item, which we just replaced. So let's update this to be the new :reminders menu item:
helpers.ex
1
def home_path(nil), do: "/"
2
def home_path(current_user), do:
3
RemindlyWeb.Menus.get_link(:reminders, current_user).path
Copied!
Now refresh the page and we can see the sidebar has updated!
A new menu item has appeared!
However, it's not highlighted 😀. To highlight it, we need to pass the current_page attribute to the <.layout> component. Open up index.html.heex for reminders and you'll see the <.layout> call up the top.
"change_me" looks suspicious
We need to match this atom to the name field in the menus get_link/2 function. Let's set the current page for both index.html.heex and show.html.heex.
Set the current page in both the index and show templates
And now our menu item should be highlighted both on the index page and when you click "Show" for an individual reminder.

User activity logging

We've found that at some point 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 complete. 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
1
defmodule Remindly.Logs.Log do
2
...
3
​
4
@action_options [
5
"update_profile",
6
"register",
7
"sign_in",
8
"sign_out",
9
"confirm_new_email",
10
"delete_user",
11
"create_reminder",
12
"complete_reminder"
13
]
14
15
...
Copied!
Now we can do the actual logging. Go to form_component.ex and add a log in the create action.
form_component.ex
1
defp save_reminder(socket, :new, reminder_params) do
2
reminder_params = Map.put(reminder_params, "user_id", socket.assigns.current_user.id)
3
​
4
case Reminders.create_reminder(reminder_params) do
5
{:ok, reminder} ->
6
Remindly.Logs.log_async("create_reminder", %{
7
user: socket.assigns.current_user,
8
metadata: %{
9
reminder_id: reminder.id,
10
reminder_label: reminder.label
11
}
12
})
13
​
14
{:noreply,
15
socket
16
|> put_flash(:info, "Reminder created successfully")
17
|> push_redirect(to: socket.assigns.return_to)}
18
​
19
{:error, %Ecto.Changeset{} = changeset} ->
20
{:noreply, assign(socket, changeset: changeset)}
21
end
22
end
Copied!
Usually for new tables like "reminders" I'd create a migration and add a foreign key to the logs table (log.reminder_id), but for now we can make use of a JSON column on the logs table called metadata. In here we can store any kind of map I 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 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.
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
1
@impl true
2
def handle_event("toggle_reminder", %{"reminder" => reminder_params}, socket) do
3
reminder = Reminders.get_reminder!(reminder_params["id"])
4
​
5
case Reminders.update_reminder(reminder, %{is_done: !reminder.is_done}) do
6
{:ok, reminder} ->
7
if reminder.is_done do
8
Remindly.Logs.log_async("complete_reminder", %{
9
user: socket.assigns.current_user,
10
metadata: %{
11
reminder_id: reminder.id,
12
reminder_label: reminder.label
13
}
14
})
15
end
16
​
17
updated_reminders = Util.replace_object_in_list(socket.assigns.reminders, reminder)
18
{:noreply, assign(socket, reminders: updated_reminders)}
19
{:error, _changeset} ->
20
{:noreply, put_flash(socket, :error, "Something went wrong.")}
21
end
22
end
Copied!
Now, when we toggle a reminder to "done", a new log will appear.

Live logs

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

Reminder emails

Next we need to implement an email reminder that gets sent the day a reminder is overdue. If we head to dev we can see a list of our transactional emails.
How to get to "Dev"
The "Email templates" section - a place to see all transaction emails in action
We want to create a new one and show it in here so we can visualise it while we develop it. Before we do, let's do a quick overview of how emails work in Petal Pro. Open up email.ex and you will see a list of functions that generate Swoosh email structs. Eg:
1
def confirm_register_email(email, url) do
2
base_email()
3
|> to(email)
4
|> subject("Confirm instructions")
5
|> render_body("confirm_register_email.html", %{url: url})
6
|> premail()
7
end
Copied!
If I run this function in IEX I can see the Swoosh struct:
An Swoosh.Email struct can be delivered to an email address by a Swoosh mailer (see mailer.ex). Eg:
1
Remindly.Email.confirm_register_email(user.email, url)
2
|> Remindly.Mailer.deliver()
Copied!
So email.ex creates the Swoosh structs and the functions for actually delivering emails like the previous code example are in user_notifier.ex. Think of email.ex functions like view templates, and user_notifer.ex functions like controller actions.
So our steps will be:
  1. 1.
    Create the function to generate a Swoosh struct in email.ex
  2. 2.
    Ensure it looks good by creating a new action in email_testing_controller.ex that simply renders the html_body value of the struct
  3. 3.
    Create a new user_notifier.ex function for use in our application
  4. 4.
    Create a CRON job that runs once a day that finds overdue reminders and emails the user using the user_notifier.ex function

Designing the email

In email.ex, let's create a function for our new email:
email.ex
1
defmodule Remindly.Email do
2
...
3
4
def reminder(email, reminder, reminder_url) do
5
base_email()
6
|> to(email)
7
|> subject("Reminder!")
8
|> render_body("reminder.html", %{reminder: reminder, url: reminder_url})
9
|> premail()
10
end
11
12
...
13
end
Copied!
Now it expects a template reminder.html.heex. Let's add one to the /lib/remindly_web/templates/email folder:
Our new reminder.html.heex template
reminder.html.heex
1
<h1>Reminder</h1>
2
​
3
<p><%= @reminder.label %></p>
4
​
5
<EmailHelpers.button_centered to={@url}>
6
View
7
</EmailHelpers.button_centered>
Copied!
Now we can try seeing how it looks. Open up email_testing_controller.ex and do two things:
  1. 1.
    Append "reminder" to the end of @email_templates
  2. 2.
    Create a new generate_email/2 function for our new email
1
defmodule RemindlyWeb.EmailTestingController do
2
...
3
4
@email_templates [
5
"template",
6
"register_confirm_email",
7
"reset_password",
8
"change_email",
9
"reminder"
10
]
11
12
defp generate_email("reminder", current_user) do
13
reminder = %Remindly.Reminders.Reminder{
14
user_id: current_user.id,
15
label: "Do the washing.",
16
due_date: Timex.now() |> Timex.to_date()
17
}
18
​
19
Email.reminder(current_user.email, reminder, "/")
20
end
21
22
...
23
end
Copied!
Now if you refresh the "Email templates" page you can see your new email design:
Our stunning email design
The only problem is the logo is wrong. 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. We personally like Cloudinary so let's chuck our logo up there and then paste the URL into our config file.
config.exs
1
config :remindly,
2
app_name: "Remindly",
3
business_name: "Remindly Pty Ltd",
4
support_email: "[email protected]",
5
mailer_default_from_name: "Remindly",
6
mailer_default_from_email: "[email protected]",
7
logo_url_for_emails: "https://res.cloudinary.com/wickedsites/image/upload/v1646277219/petal_marketing/remindly_logo_tsvksl.png",
8
seo_description: "Reminder app",
9
css_theme_default: "dark"
Copied!
After restarting the server:
Our own logo now displays
Great, now that the email is ready to go we can implement the user_notifier.ex function that accepts a reminder ecto struct as a parameter and sends the reminder email for us:
1
defmodule Remindly.Accounts.UserNotifier do
2
...
3
4
def deliver_reminder(reminder) do
5
reminder = Remindly.Repo.preload(reminder, :user)
6
url = RemindlyWeb.Router.Helpers.reminder_show_url(RemindlyWeb.Endpoint, :show, reminder)
7
​
8
Email.reminder(reminder.user.email, reminder, url)
9
|> deliver
10
end
11
12
...
13
end
Copied!
Now we can test a sent email in IEX:
iex -S mix phx.server
1
reminder = DB.last(Remindly.Reminders.Reminder)
2
user = DB.last(User)
3
Remindly.Accounts.UserNotifier.deliver_reminder(reminder)
Copied!
Click on "Sent emails" in the sidebar to see that it worked properly. If we cmd-click the button it should take us to the right reminder.
The "Sent emails" section shows any emails that have been sent off

Sending the email for overdue reminders

With an email ready to be sent to people who have been lazy, let's create a CRON job to send off the emails. We use Oban for jobs, which supports a CRON schedule. Here's what we'll do:
  1. 1.
    We create a worker Remindly.Workers.ReminderWorker, which has a function perform/1 that will find overdue reminders and send the reminder emails
  2. 2.
    We tell Oban to run this worker once per day

Reminder worker

Petal Pro comes with an example worker you can check out (example_worker.ex). In the same folder, let's create reminder_worker.ex:
Our new worker
And then fill it with this: