Surface - A component-based library for Phoenix LiveView

It doesn’t need to be exclusively one way or the other.

I personally no longer go the “dead view” route :laughing: for new projects with Phoenix. With that said, I still have a few classic http endpoints where it doesn’t make much sense to port them over to LiveView (think auth pages).

I’ve been keeping tabs on Surface ever since it surfaced :smiley: . It’s such a joy to work with and complements LiveView so well, that I can’t imagine myself developing LiveView apps without it at this point.

That’s what kept me away from Surface at first. I’ve wanted to stay as close as possible to LiveView, considering it hasn’t reached 1.0 yet, without adding an extra layer of complexity on top of it. I was wrong on that call. Just as I was wrong about Tailwind and it’s approach to CSS (it’s one of those things you have to try and keep at it to understand its benefits).

1 Like

Thanks for the insight. And I will definitely adopt the “dead view” term. :laughing:

This is exactly what I had in mind. We have a login screen that uses form fields, that would otherwise use the exact markup as the ones inside our live views. Do we bite the bullet and just suffer the duplication between the markup in this regular EEx template and our Surface components?

That’s what I did for the /login, /signup, /password-reset etc views. I have them hard-coded with classic Phoenix.HTML code until it will be possible to bring in Surface stateless components. They’re rather simple in nature, so the hardest thing was to keep the style in sync with the rest of the app (how an input/button/form is styled since you can’t re-use the components where these are set).

I even do auth in live_view. It is not the most efficient way but the uniformity is very pleasing.

How do you do it? I think you still need to do like a page redirect to set the cookies, so at one point the LiveView has to redirect to a controller, which performs session operations, and then goes back to LiveView, right? That’s how I do it but maybe I miss on something…

I don’t use cookie. I use LocalStorage to store the JWT token client side and send it back to LV on mount for it to verify.
If the token check out, go ahead. If not, push_redirect to a simple LV backed login form.

Ah yes, that’s doable. But you need a hook for that, right?

Yes. It is fairly simple, please refer to the linked code. This is my javascript in its entirety for one project; all 60 lines of it.

3 Likes

For authenticating with Surface, I recommend using phx_trigger_action to send the user to a regular form handler route rather than trying to do it completely in the LiveView:

https://hexdocs.pm/phoenix_live_view/form-bindings.html#submitting-the-form-action-over-http

I initially was unaware of this feature and went a more hacky route. You don’t need to involve extra tokens or local storage or anything. If you’re looking for an out of the box solution, phx_gen_auth is great:

https://hexdocs.pm/phx_gen_auth/overview.html

Using it with LiveView/Surface involves some translation, but the important back end bits (which use industry grade security) stay the same.

3 Likes

Yes my way is unorthodox but I have exactly one secure token in localStorage, no cookie. The token is the same industry grade JWT token (I use Guardian like before), the password hash (I use Argon2 like before) is not exposed to the client side. And I have zero controller. I believe it is as secure as a controller based auth.

For authentication with LiveView, isn’t it possible to store a token in the socket, then have liveview use this token to load user details on mount ?

You can store anything in the socket, but the point is to auth the client as soon as he come in, so something has to be on the client side, be it cookie or localStorage. And once the client is auth’ed. you don’t need to store the token anymore, just the auth’ed state and his account details.

On the topic of mixing Surface.LiveViews with classic (dead giggle) views, is there a way to tell Surface to use a live.html layout by default?

I know I can tell each individual module to:

  use Surface.LiveView,
    layout: {PlatformWeb.LayoutView, "live.html"}

But can I do it globally?

For plain LiveView we would do:

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {PlatformWeb.LayoutView, "live.html"}

      unquote(view_helpers())
    end
  end

in our myapp_web.ex.

Is there an equivalent for surface?

Just replace use Phoenix.LiveView in the live_view macro with use Surface.LiveView

or make a new macro for :surface_live_view

  def surface_live_view do
    quote do
      use Surface.LiveView,
        layout: {PlatformWeb.LayoutView, "live.html"}

      unquote(view_helpers())
    end
  end
4 Likes

The concept of a layout is obsolete in Surface. With a Surface component you can have multiple, named slots, much more expressive than a single <%= @inner_content %>

1 Like

I see what you mean, but I don’t know how to set that up? I have a parent .html.eex layout that is shared between classic controllers and Surface.LiveViews. Now I need a way to output the live_flash only for LiveViews (what live.html does if you generate a new phoenix app with --live).

If you just need the flash, you can do:

main.sface:

<div id="main-content" class="content" phx-hook="Main">
    <div role="alert" :for={{ type <- Map.keys(@messages) }}
       class={{ "alert", alert_class(type) }}
       phx-value-key={{ type }} phx-click="lv:clear-flash">
        {{ @messages[type] }}
    </div>
    <slot />
</div>

include a Main component in your liveviews::

<Main :props={{ messages: @flash }}>
YOUR STUFF
</Main>

Hmm, but then I’d have to repeat that in every LiveView? For now I’ve resorted to @harmon25’s trick with :surface_live_view (thanks!).

I prefer explicit specification over implicit. Also with Liveview I typically have far fewer LiveViews than classical controller/view pairs.

1 Like

@mfilej I also use the suggestion of @derek-zhou

In most app I develop, I need several layouts as they deserve different purpose, and most importantly, they have different designs.

I often use 3 types of layout:

  • a LandingLayout that is used only for the landing page of the website and any related stuff:
    • marketing-oriented design
    • few user interactions
    • components are not reused
  • a AppLayout or MainLayout that is used in the application itself when the user is connected
    • UX oriented design
    • a lot of user interactions
    • components are reused everywhere in the app
  • an AdminLayout that is used only by the team
    • Ugly design (^^)
    • medium user interactions
    • components are reused in the backend

By opening one of my live view, it’s really clear for me which layout is used on and what are the features that layout gives me access to!

In 80% of the time, my live views look like this

<AppLayout
  flashes={{ @flash }}
  current_user={{ @current_user }}
/>
  My Content
</AppLayout>

In the 20 remaining %, I am able to tweak my layout for specific pages explicitly without duplicating it.

<AppLayout
  show_sidebar={{ false }} <-- NO SIDEBAR FOR THAT LIVE VIEW
  flashes={{ @flash }}
  current_user={{ @current_user }}
/>
  My content

  <PageTitle>
    Override the design of the page title for that specific live view
  </PageTitle>

  <RightColumn>
    Useful slottable component that extends my layout and that is not present by default
  </RightColumn>
  # or
  <ProfileRightColumn user={{ current_user }} /> <-- A PREDIFINED RIGHT COLUMN THAT IS EXPLICIT OF ITS INTENT (It is "just" a wrapper arround `<RightColumn>...</RightColumn>` with predifined `props`
</AppLayout>

Finally, here is an example of one of my layout with some comments (I removed classes and I only show the render function):

      <div
        x-data="{ open: false }"
        @keydown.window.escape="open = false"
      >
        <slot name="sidebar" :if={{ @show_sidebar }}> <-- WRAPPING COMPONENTS INTO A SLOT ALLOWS ME TO OVERRIDE THEM EASILY
          <Sidebar id="sidebar" />
        </slot>

        <slot name="secondary_sidebar" /> <-- OPTIONAL EXTENSION

        <div>
          <slot name="header_menu"> <-- LIKE SIDEBAR, IF THE HEADER HAS NOT BEEN OVERRIDED, SHOW THE DEFAULT ONE
            <HeaderMenu id="header-menu" />
          </slot>

          <!-- Main content -->
          <div>

            <!-- Primary column -->
            <main>
              <section>
                <h1>{{ @page_title }}</h1> <-- PROP FOR THAT LAYOUT
                <slot name="default" /> <-- MAIN CONTENT
              </section>
            </main>

            <!-- Optional secondary column -->
            <slot
              name="secondary_column"
              :if={{ show_secondary_column(assigns) }} /> <-- OPTIONNAL EXTENSION WHOSE VISIBILITY IS MANAGED BY THE CHILD COMPONENT
          </div>
        </div>
      </div>
      <FlashMessages flashes={{ @flashes }} /> <-- FLASHES WRAPPER

So yes you have to repeat a few lines of code, but I really believe you gain in clarity and in functionality :slight_smile:

Hope this will help other Surface users! Feel free to comment and get my yours feedback !

10 Likes