Skip to content

Commit

Permalink
Move some things around
Browse files Browse the repository at this point in the history
  • Loading branch information
chrismccord committed Aug 16, 2023
1 parent 707db47 commit c3be3a1
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 152 deletions.
59 changes: 58 additions & 1 deletion lib/phoenix_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ defmodule Phoenix.Component do

## Functions

alias Phoenix.LiveView.{Static, Socket}
alias Phoenix.LiveView.{Static, Socket, AsyncResult}
@reserved_assigns Phoenix.Component.Declarative.__reserved__()
# Note we allow live_action as it may be passed down to a component, so it is not listed
@non_assignables [:uploads, :streams, :socket, :myself]
Expand Down Expand Up @@ -2868,4 +2868,61 @@ defmodule Phoenix.Component do
%><% end %>
"""
end

@doc """
Renders an async assign with slots for the different loading states.
## Examples
```heex
<.async_result :let={org} assign={@org}>
<:loading>Loading organization...</:loading>
<:empty>You don't have an organization yet</:error>
<:error :let={{_kind, _reason}}>there was an error loading the organization</:error>
<:canceled :let={_reason}>loading canceled</:canceled>
<%= org.name %>
<.async_result>
```
"""
attr.(:assign, :any, required: true)
slot.(:loading, doc: "rendered while the assign is loading")

# TODO decide if we want an canceled slot
slot.(:canceled, dock: "rendered when the assign is canceled")

# TODO decide if we want an empty slot
slot.(:empty,
doc:
"rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let."
)

slot.(:failed,
doc:
"rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let."
)

def async_result(assigns) do
case assigns.assign do
%AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? ->
if assigns.empty != [] && result in [nil, []] do
~H|<%= render_slot(@empty, @assign.result) %>|
else
~H|<%= render_slot(@inner_block, @assign.result) %>|
end

%AsyncResult{state: :loading} ->
~H|<%= render_slot(@loading) %>|

%AsyncResult{state: {:error, {:canceled, reason}}} ->
if assigns.canceled != [] do
assigns = Phoenix.Component.assign(assigns, reason: reason)
~H|<%= render_slot(@canceled, @reason) %>|
else
~H|<%= render_slot(@failed, @assign.state) %>|
end

%AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] ->
~H|<%= render_slot(@failed, @assign.state) %>|
end
end
end
151 changes: 3 additions & 148 deletions lib/phoenix_live_view/async_result.ex
Original file line number Diff line number Diff line change
@@ -1,97 +1,9 @@
defmodule Phoenix.LiveView.AsyncResult do
@moduledoc ~S'''
Adds async functionality to LiveViews and LiveComponents.
Performing asynchronous work is common in LiveViews and LiveComponents.
It allows the user to get a working UI quicky while the system fetches some
data in the background or talks to an external service. For async work,
you also typically need to handle the different states of the async operation,
such as loading, error, and the successful result. You also want to catch any
errors or exits and translate it to a meaningful update in the UI rather than
crashing the user experience.
## Async assigns
The `assign_async/3` function takes a name, a list of keys which will be assigned
asynchronously, and a function that returns the result of the async operation.
For example, let's say we want to async fetch a user's organization from the database,
as well as their profile and rank:
def mount(%{"slug" => slug}, _, socket) do
{:ok,
socket
|> assign(:foo, "bar")
|> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)} end)
|> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)}
end
Here we are assigning `:org` and `[:profile, :rank]` asynchronously. The async function
must return a `{:ok, assigns}` or `{:error, reason}` tuple, where `assigns` is a map of
the keys passed to `assign_async`. If the function returns other keys or a different
set of keys, an error is raised.
The state of the async operation is stored in the socket assigns within an
`%AsyncResult{}`. It carries the loading and error states, as well as the result.
For example, if we wanted to show the loading states in the UI for the `:org`,
our template could conditionally render the states:
```heex
<div :if={@org.state == :loading}>Loading organization...</div>
<div :if={org = @org.ok? && @org.result}}><%= org.name %> loaded!</div>
```
The `with_state` function component can also be used to declaratively
render the different states using slots:
```heex
<AsyncResult.with_state :let={org} assign={@org}>
<:loading>Loading organization...</:loading>
<:empty>You don't have an organization yet</:error>
<:error :let={{_kind, _reason}}>there was an error loading the organization</:error>
<:canceled :let={_reason}>loading canceled</:canceled>
<%= org.name %>
<AsyncResult.with_state>
```
Additionally, for async assigns which result in a list of items, you
can consume the assign directly. It will only enumerate
the results once the results are loaded. For example:
```heex
<div :for={orgs <- @orgs}><%= org.name %></div>
```
## Arbitrary async operations
Sometimes you need lower level control of asynchronous operations, while
still receiving process isolation and error handling. For this, you can use
`start_async/3` and the `AsyncResult` module directly:
def mount(%{"id" => id}, _, socket) do
{:ok,
socket
|> assign(:org, AsyncResult.new(:org))
|> start_async(:my_task, fn -> fetch_org!(id) end)
end
def handle_async(:org, {:ok, fetched_org}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end
def handle_async(:org, {:exit, reason}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.exit(org, reason))}
end
`start_async/3` is used to fetch the organization asynchronously. The
`handle_async/3` callback is called when the task completes or exists,
with the results wrapped in either `{:ok, result}` or `{:exit, reason}`.
The `AsyncResult` module is used to direclty to update the state of the
async operation, but you can also assign any value directly to the socket
if you want to handle the state yourself.
Provides a datastructure for tracking the state of an async assign.
See the `Async Operations` section of the `Phoenix.LiveView` docs for more information.
'''
use Phoenix.Component

defstruct name: nil,
keys: [],
Expand Down Expand Up @@ -152,63 +64,6 @@ defmodule Phoenix.LiveView.AsyncResult do
%AsyncResult{result | state: :ok, ok?: true, result: value}
end

@doc """
Renders an async assign with slots for the different loading states.
## Examples
```heex
<AsyncResult.with_state :let={org} assign={@org}>
<:loading>Loading organization...</:loading>
<:empty>You don't have an organization yet</:error>
<:error :let={{_kind, _reason}}>there was an error loading the organization</:error>
<:canceled :let={_reason}>loading canceled</:canceled>
<%= org.name %>
<AsyncResult.with_state>
```
"""
attr :assign, :any, required: true
slot(:loading)

# TODO decide if we want an canceled slot
slot(:canceled)

# TODO decide if we want an empty slot
slot(:empty,
doc:
"rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let."
)

slot(:failed,
doc:
"rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let."
)

def with_state(assigns) do
case assigns.assign do
%AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? ->
if assigns.empty != [] && result in [nil, []] do
~H|<%= render_slot(@empty, @assign.result) %>|
else
~H|<%= render_slot(@inner_block, @assign.result) %>|
end

%AsyncResult{state: :loading} ->
~H|<%= render_slot(@loading) %>|

%AsyncResult{state: {:error, {:canceled, reason}}} ->
if assigns.canceled != [] do
assigns = Phoenix.Component.assign(assigns, reason: reason)
~H|<%= render_slot(@canceled, @reason) %>|
else
~H|<%= render_slot(@failed, @assign.state) %>|
end

%AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] ->
~H|<%= render_slot(@failed, @assign.state) %>|
end
end

defimpl Enumerable, for: Phoenix.LiveView.AsyncResult do
alias Phoenix.LiveView.AsyncResult

Expand Down
5 changes: 2 additions & 3 deletions test/support/live_views/general.ex
Original file line number Diff line number Diff line change
Expand Up @@ -441,22 +441,21 @@ end

defmodule Phoenix.LiveViewTest.AsyncLive.LC do
use Phoenix.LiveComponent
alias Phoenix.LiveView.AsyncResult

def render(assigns) do
~H"""
<div>
<%= if @enum do %>
<div :for={i <- @lc_data}><%= i %></div>
<% end %>
<AsyncResult.with_state :let={data} assign={@lc_data}>
<.async_result :let={data} assign={@lc_data}>
<:loading>lc_data loading...</:loading>
<:canceled>lc_data canceled</:canceled>
<:empty :let={_res}>no lc_data found</:empty>
<:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %></:failed>
lc_data: <%= inspect(data) %>
</AsyncResult.with_state>
</.async_result>
</div>
"""
end
Expand Down

0 comments on commit c3be3a1

Please sign in to comment.