Introduction
One of the most handy features of Phoenix 1.6+ is the ability to use mix phx.gen.live
to easily generate Liveviews, templates, and a context for a resource. However, if you are utilizing Surface in your project, you need to modify some of the files it creates to make them usable in your project.
To start off, if you don’t already have a Phoenix Liveview project with SurfaceUI installed, you can clone this surface-starter repo to get up and running right away. Follow the instructions in the README and then start your project running with mix phx.server
. If you prefer to see the final project before starting the walk-through, you can check out the completed-project branch.
Getting Started
The first thing you need to do is cd my_app
then run
mix phx.gen.live Contacts Contact contacts name:string email:string phone:string
While it generates a plethora of files, the following liveview and template files within /lib/my_app_web/live/contact_live/
are primarily what we’ll be modifying:
- index.ex
- index.html.heex
- show.ex
- show.html.heex
- form_component.ex
- form_component.html.heex
We will also be modifying live_helpers.ex
, located in /lib/my_app_web/
, which contains a modal helper function utilized in your show
and index
templates.
After running the command above, you need to add paths to the files generated in your lib/my_app_web/router.ex
file by adding the following code. For this tutorial, you will add it under get "/", PageController, :index
, which for me is line 20.
live "/contact", ContactLive.Index, :index
live "/contact/new", ContactLive.Index, :new
live "/contact/:id/edit", ContactLive.Index, :edit
live "/contact/:id", ContactLive.Show, :show
live "/contact/:id/show/edit", ContactLive.Show, :edit
You also need to run
mix ecto.migrate
to generate your database table.
After doing this, I like to go ahead and seed an entry into my database so I can see what I’m working with early in the tutorial. I’ve included a seed.exs
file, so at this point you should run:
mix run priv/repo/seeds.exs
Converting Content Display Files
To convert your files to be usable with Surface, the first thing you need to do is to open your index.ex
file and convert your :live_view
into a Surface liveview. You also need to alias in your Routes and a few Surface components. To do so, replace line 1, use MyAppWeb, :live_view
, with the following.
use Surface.LiveView
alias MyAppWeb.Router.Helpers, as: Routes
alias Surface.Components.{LivePatch, Link, LiveRedirect}
Next, rename index.html.heex
to index.sface
and open it up.
At this point, I first like to go ahead and temporarily remove the modal component, lines 3–14, because the modal will not be fixed until the last step of the tutorial. I typically cut and paste them into a scratchpad to reference later, but I will also have it available for you to copy and paste later in the tutorial.
Then, find your for
loop and replace it with Surface component syntax, as outlined below. In the following code, I am also replacing the Phoenix live_redirect, live_patch, and link with Surface components.
{#for contact <- @contacts}
<tr id={"contact-#{contact.id}"}>
<td>{contact.name}</td>
<td>{contact.email}</td>
<td>{contact.phone}</td>
<td>
<LiveRedirect to={Routes.contact_show_path(@socket, :show, contact)}>Show</LiveRedirect>
<LivePatch to={Routes.contact_index_path(@socket, :edit, contact)}>Edit</LivePatch>
<Link click="delete" to="#" values={id: contact.id} opts={data: [confirm: "Are you sure?"]} "Delete">Delete</Link>
</td>
</tr>
{/for}
And then replace the final live_patch with a Surface LivePatch, like so:
<span><LivePatch to={Routes.contact_index_path(@socket, :new)}>New Contact</LivePatch></span>
At this point, you should be able to refresh localhost:4000/contact
and see your table headers, any seeded data displayed, and a button to create a New Contact.
After this, you’re going to do almost the exact same thing in show.ex
as you did in index.ex
– replace the first line, use MyAppWeb, :live_view
, with the following:
use Surface.LiveView
alias MyAppWeb.Router.Helpers, as: Routes
alias Surface.Components.{LivePatch, Link, LiveRedirect}
Rename show.html.heex
to show.sface
, temporarily remove the modal display code, and then change your listing to look like the following:
<ul>
<li>
<strong>Name:</strong>
{ @contact.name }
</li>
<li>
<strong>Email:</strong>
{ @contact.email }
</li>
<li>
<strong>Phone:</strong>
{ @contact.phone }
</li>
</ul>
<LivePatch to={Routes.contact_show_path(@socket, :edit, @contact)} class="button">Edit</LivePatch>
<LiveRedirect to={Routes.contact_index_path(@socket, :index)}>Back</LiveRedirect>
Now your show view is all fixed up. If you seeded data into your database, you should be able to click the “show” button on the contact page and see your full entry.
Displaying the Form Component
The first thing we do again is replace the second line of form_component.ex
– use MyAppWeb, :live_component
– with a Surface LiveComponent and a couple necessary aliases:
use Surface.LiveComponent
alias Surface.Components.Form
alias Surface.Components.Form.{Field, Label, TextInput, Submit, ErrorTag}
Then, to be able to access some props in your surface template files, you need to add the following code under your last alias (which for me was alias MyAppWeb.Contacts
):
prop contact, :struct, required: true
prop action, :atom, default: :new
prop return_to, :string
Next, rename form.html.heex
to form.sface
, open it up, and edit it to use Surface components rather than Phoenix components. This is what the new file looks like:
<Form
for={@changeset}
change="validate"
submit="save">
<Field name={:name}>
<Label/>
<div class="control">
<TextInput value={@contact.name}/>
<ErrorTag/>
</div>
</Field>
<Field name={:email}>
<Label/>
<div class="control">
<TextInput value={@contact.email}/>
<ErrorTag/>
</div>
</Field>
<Field name={:phone}>
<Label/>
<div class="control">
<TextInput value={@contact.phone}/>
<ErrorTag/>
</div>
</Field>
<Submit>Save</Submit>
</Form>
Go back into your index.sface
file. In this file, add the following code to display the form before anything else when on the edit
or new
routes:
{#if @live_action in [:new, :edit]}
<FormComponent
id={@contact.id || :new}
action={@live_action}
contact={@contact}
return_to={Routes.contact_index_path(@socket, :index)}
/>
{/if}
Now visit localhost:4000/contact/new
. You should see a form displayed at the top of the page, which you can fill out and save a new entry to your database.
Converting the Modal Component
The final remaining item is converting the live_helpers.ex
file to work with Surface. First, I like to rename my file live_modal.ex
because this file will only contain the modal function.
Then, replace the first line, the module definitition, with this:defmodule MyAppWeb.LiveHelpers.LiveModal do
Next, replace the next two lines,
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
with the following
use Surface.LiveComponent
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
prop return_to, :string
prop opts, :keyword
slot default
And add in a couple props and a slot under that,
prop return_to, :string
prop opts, :keyword
slot default
The next change is a big one. We are changing the the entire def modal(assigns)
into a def render(assigns)
and changing our assigns to be props, which should look like this:
def render(assigns) do
~F"""
<div id="modal" class="phx-modal fade-in" phx-remove={hide_modal()}>
<div
id="modal-content"
class="phx-modal-content fade-in-scale"
phx-click-away={JS.dispatch("click", to: "#close")}
phx-window-keydown={JS.dispatch("click", to: "#close")}
phx-key="escape"
>
{#if @return_to}
<LivePatch
to={@return_to}
class="phx-modal-close"
>
<#Raw>
×
</#Raw>
</LivePatch>
{#else}
<a id="close" href="#" class="phx-modal-close" phx-click={hide_modal()}>✖</a>
{/if}
<#slot />
</div>
</div>
"""
end
That completes the modal. Now you need to go back into index.ex
and show.ex
and alias your modal in so it’s accessible in both of those liveviews.
alias MyAppWeb.LiveHelpers.LiveModal
And finally, to pull that modal back into the index and show template files, you need to surround your <FormComponent>
with a <LiveModal>
in index.sface
, like so:
{#if @live_action in [:new, :edit]}
<LiveModal id="modal" return_to={Routes.contact_index_path(@socket, :index)}>
<FormComponent
id={@contact.id || :new}
action={@live_action}
contact={@contact}
return_to={Routes.contact_index_path(@socket, :index)}
/>
</LiveModal>
{/if}
Finally, do the same thing in your show.sface
file but change the return_to path to be contact_show_path
:
{#if @live_action in [:edit]}
<LiveModal id="modal" return_to={Routes.contact_show_path(@socket, :show, @contact)}>
<FormComponent
id={@contact.id || :new}
action={@live_action}
contact={@contact}
return_to={Routes.contact_show_path(@socket, :show, @contact)}
/>
</LiveModal>
{/if
And that’s it! Iif you had any issues with the steps above, you can check out the completed branch of the repo to see the code together.
We're building an AI-powered Product Operations Cloud, leveraging AI in almost every aspect of the software delivery lifecycle. Want to test drive it with us? Join the ProdOps party at ProdOps.ai.