illustration green arm bird tattoo Revelry blog

Converting Phoenix mix.gen.live Files to Surface Compatible Files

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, as described here, 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>
            &times;
            </#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.