Context/LiveView generator

This commit is contained in:
Mitchell Hanberg 2020-06-30 20:40:38 -04:00
parent a3ec57344a
commit 4498eabedb
13 changed files with 628 additions and 2 deletions

View file

@ -2,6 +2,24 @@
## Master
## 0.6.0-alpha.1
### Generators
You can now use `mix temple.gen.live Context Schema table_name col:type` in the same way you can with Phoenix.
### Other
- Make a note in the README to set the filetype for Live temple templates to `lexs`. You should be able to set this extension to use Elixir for syntax highlighting in your editor. In vim, you can add the following to your `.vimrc`
```vim
augroup elixir
autocmd!
autocmd BufRead,BufNewFile *.lexs set filetype=elixir
augroup END
```
## 0.6.0-alpha.0
### Breaking!

View file

@ -65,7 +65,9 @@ Add the templating engine to your Phoenix configuration.
config :phoenix, :template_engines,
exs: Temple.Engine
# or for LiveView support
exs: Temple.LiveViewEngine
# this will work for files named like `index.html.lexs`
# you can enable Elixir syntax highlighting in your editor
lexs: Temple.LiveViewEngine
# config/dev.exs
config :your_app, YourAppWeb.Endpoint,

View file

@ -207,7 +207,7 @@ if Code.ensure_loaded?(Mix.Phoenix) do
if context.generate?, do: Gen.Context.print_shell_instructions(context)
end
defp inputs(%Schema{} = schema) do
def inputs(%Schema{} = schema) do
Enum.map(schema.attrs, fn
{_, {:references, _}} ->
{nil, nil, nil}

View file

@ -0,0 +1,254 @@
if Code.ensure_loaded?(Mix.Phoenix) do
defmodule Mix.Tasks.Temple.Gen.Live do
@shortdoc "Generates LiveView, templates, and context for a resource"
@moduledoc """
Generates LiveView, templates, and context for a resource.
mix temple.gen.live Accounts User users name:string age:integer
The first argument is the context module followed by the schema module
and its plural name (used as the schema table name).
The context is an Elixir module that serves as an API boundary for
the given resource. A context often holds many related resources.
Therefore, if the context already exists, it will be augmented with
functions for the given resource.
When this command is run for the first time, a `ModalComponent` and
`LiveHelpers` module will be created, along with the resource level
LiveViews and components, including an `IndexLive`, `ShowLive`, `FormComponent`
for the new resource.
> Note: A resource may also be split
> over distinct contexts (such as `Accounts.User` and `Payments.User`).
The schema is responsible for mapping the database fields into an
Elixir struct. It is followed by an optional list of attributes,
with their respective names and types. See `mix phx.gen.schema`
for more information on attributes.
Overall, this generator will add the following files to `lib/`:
* a context module in `lib/app/accounts.ex` for the accounts API
* a schema in `lib/app/accounts/user.ex`, with an `users` table
* a view in `lib/app_web/views/user_view.ex`
* a LiveView in `lib/app_web/live/user_live/show_live.ex`
* a LiveView in `lib/app_web/live/user_live/index_live.ex`
* a LiveComponent in `lib/app_web/live/user_live/form_component.ex`
* a LiveComponent in `lib/app_web/live/modal_component.ex`
* a helpers modules in `lib/app_web/live/live_helpers.ex`
## The context app
A migration file for the repository and test files for the context and
controller features will also be generated.
The location of the web files (LiveView's, views, templates, etc) in an
umbrella application will vary based on the `:context_app` config located
in your applications `:generators` configuration. When set, the Phoenix
generators will generate web files directly in your lib and test folders
since the application is assumed to be isolated to web specific functionality.
If `:context_app` is not set, the generators will place web related lib
and test files in a `web/` directory since the application is assumed
to be handling both web and domain specific functionality.
Example configuration:
config :my_app_web, :generators, context_app: :my_app
Alternatively, the `--context-app` option may be supplied to the generator:
mix temple.gen.live Sales User users --context-app warehouse
## Web namespace
By default, the controller and view will be namespaced by the schema name.
You can customize the web module namespace by passing the `--web` flag with a
module name, for example:
mix temple.gen.live Sales User users --web Sales
Which would generate a LiveViews inside `lib/app_web/live/sales/user_live/` and a
view at `lib/app_web/views/sales/user_view.ex`.
## Customising the context, schema, tables and migrations
In some cases, you may wish to bootstrap HTML templates, LiveViews,
and tests, but leave internal implementation of the context or schema
to yourself. You can use the `--no-context` and `--no-schema` flags
for file generation control.
You can also change the table name or configure the migrations to
use binary ids for primary keys, see `mix phx.gen.schema` for more
information.
"""
use Mix.Task
alias Mix.Phoenix.{Context}
alias Mix.Tasks.Phx.Gen
@doc false
def run(args) do
if Mix.Project.umbrella?() do
Mix.raise("mix temple.gen.live can only be run inside an application directory")
end
{context, schema} = Gen.Context.build(args)
Gen.Context.prompt_for_code_injection(context)
binding = [
context: context,
schema: schema,
inputs: Mix.Tasks.Temple.Gen.Html.inputs(schema)
]
paths = [".", :temple]
prompt_for_conflicts(context)
context
|> copy_new_files(binding, paths)
|> maybe_inject_helpers()
|> print_shell_instructions()
end
defp prompt_for_conflicts(context) do
context
|> files_to_be_generated()
|> Kernel.++(context_files(context))
|> Mix.Phoenix.prompt_for_conflicts()
end
defp context_files(%Context{generate?: true} = context) do
Gen.Context.files_to_be_generated(context)
end
defp context_files(%Context{generate?: false}) do
[]
end
defp files_to_be_generated(%Context{schema: schema, context_app: context_app}) do
web_prefix = Mix.Phoenix.web_path(context_app)
test_prefix = Mix.Phoenix.web_test_path(context_app)
web_path = to_string(schema.web_path)
live_subdir = "#{schema.singular}_live"
[
{:eex, "show.ex", Path.join([web_prefix, "live", web_path, live_subdir, "show.ex"])},
{:eex, "index.ex", Path.join([web_prefix, "live", web_path, live_subdir, "index.ex"])},
{:eex, "form_component.ex",
Path.join([web_prefix, "live", web_path, live_subdir, "form_component.ex"])},
{:eex, "form_component.html.lexs",
Path.join([web_prefix, "live", web_path, live_subdir, "form_component.html.lexs"])},
{:eex, "index.html.lexs",
Path.join([web_prefix, "live", web_path, live_subdir, "index.html.lexs"])},
{:eex, "show.html.lexs",
Path.join([web_prefix, "live", web_path, live_subdir, "show.html.lexs"])},
{:eex, "live_test.exs",
Path.join([test_prefix, "live", web_path, "#{schema.singular}_live_test.exs"])},
{:new_eex, "modal_component.ex", Path.join([web_prefix, "live", "modal_component.ex"])},
{:new_eex, "live_helpers.ex", Path.join([web_prefix, "live", "live_helpers.ex"])}
]
end
defp copy_new_files(%Context{} = context, binding, paths) do
files = files_to_be_generated(context) |> IO.inspect(label: "FILES")
Mix.Phoenix.copy_from(
paths |> IO.inspect(label: "PATHS"),
"priv/templates/temple.gen.live",
binding,
files
)
if context.generate?,
do: Gen.Context.copy_new_files(context, Mix.Phoenix.generator_paths(), binding)
context
end
defp maybe_inject_helpers(%Context{context_app: ctx_app} = context) do
web_prefix = Mix.Phoenix.web_path(ctx_app)
[lib_prefix, web_dir] = Path.split(web_prefix)
file_path = Path.join(lib_prefix, "#{web_dir}.ex")
file = File.read!(file_path)
inject = "import #{inspect(context.web_module)}.LiveHelpers"
if String.contains?(file, inject) do
:ok
else
do_inject_helpers(context, file, file_path, inject)
end
context
end
defp do_inject_helpers(context, file, file_path, inject) do
Mix.shell().info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path)])
new_file =
String.replace(
file,
"import Phoenix.LiveView.Helpers",
"import Phoenix.LiveView.Helpers\n #{inject}"
)
if file != new_file do
File.write!(file_path, new_file)
else
Mix.shell().info("""
Could not find Phoenix.LiveView.Helpers imported in #{file_path}.
This typically happens because your application was not generated
with the --live flag:
mix temple.new my_app --live
Please make sure LiveView is installed and that #{inspect(context.web_module)}
defines both `live_view/0` and `live_component/0` functions,
and that both functions import #{inspect(context.web_module)}.LiveHelpers.
""")
end
end
@doc false
def print_shell_instructions(%Context{schema: schema, context_app: ctx_app} = context) do
prefix = Module.concat(context.web_module, schema.web_namespace)
web_path = Mix.Phoenix.web_path(ctx_app)
if schema.web_namespace do
Mix.shell().info("""
Add the live routes to your #{schema.web_namespace} :browser scope in #{web_path}/router.ex:
scope "/#{schema.web_path}", #{inspect(prefix)}, as: :#{schema.web_path} do
pipe_through :browser
...
#{for line <- live_route_instructions(schema), do: " #{line}"}
end
""")
else
Mix.shell().info("""
Add the live routes to your browser scope in #{Mix.Phoenix.web_path(ctx_app)}/router.ex:
#{for line <- live_route_instructions(schema), do: " #{line}"}
""")
end
if context.generate?, do: Gen.Context.print_shell_instructions(context)
end
defp live_route_instructions(schema) do
[
~s|live "/#{schema.plural}", #{inspect(schema.alias)}Live.Index, :index\n|,
~s|live "/#{schema.plural}/new", #{inspect(schema.alias)}Live.Index, :new\n|,
~s|live "/#{schema.plural}/:id/edit", #{inspect(schema.alias)}Live.Index, :edit\n\n|,
~s|live "/#{schema.plural}/:id", #{inspect(schema.alias)}Live.Show, :show\n|,
~s|live "/#{schema.plural}/:id/show/edit", #{inspect(schema.alias)}Live.Show, :edit|
]
end
end
end

View file

@ -0,0 +1,55 @@
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent do
use <%= inspect context.web_module %>, :live_component
alias <%= inspect context.module %>
@impl true
def update(%{<%= schema.singular %>: <%= schema.singular %>} = assigns, socket) do
changeset = <%= inspect context.alias %>.change_<%= schema.singular %>(<%= schema.singular %>)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)}
end
@impl true
def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do
changeset =
socket.assigns.<%= schema.singular %>
|> <%= inspect context.alias %>.change_<%= schema.singular %>(<%= schema.singular %>_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do
save_<%= schema.singular %>(socket, socket.assigns.action, <%= schema.singular %>_params)
end
defp save_<%= schema.singular %>(socket, :edit, <%= schema.singular %>_params) do
case <%= inspect context.alias %>.update_<%= schema.singular %>(socket.assigns.<%= schema.singular %>, <%= schema.singular %>_params) do
{:ok, _<%= schema.singular %>} ->
{:noreply,
socket
|> put_flash(:info, "<%= schema.human_singular %> updated successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp save_<%= schema.singular %>(socket, :new, <%= schema.singular %>_params) do
case <%= inspect context.alias %>.create_<%= schema.singular %>(<%= schema.singular %>_params) do
{:ok, _<%= schema.singular %>} ->
{:noreply,
socket
|> put_flash(:info, "<%= schema.human_singular %> created successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end

View file

@ -0,0 +1,13 @@
h2 do: @title
f = form_for @changeset, "#", id: "<%= schema.singular %>-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
<%= for {label, input, error} <- inputs, input do %>
<%= label %>
<%= input %>
<%= error %>
<% end %>
submit "Save", phx_disable_with: "Saving..."
"</form>"

View file

@ -0,0 +1,46 @@
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Index do
use <%= inspect context.web_module %>, :live_view
alias <%= inspect context.module %>
alias <%= inspect schema.module %>
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :<%= schema.collection %>, list_<%= schema.plural %>())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit <%= schema.human_singular %>")
|> assign(:<%= schema.singular %>, <%= inspect context.alias %>.get_<%= schema.singular %>!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New <%= schema.human_singular %>")
|> assign(:<%= schema.singular %>, %<%= inspect schema.alias %>{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing <%= schema.human_plural %>")
|> assign(:<%= schema.singular %>, nil)
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
<%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(id)
{:ok, _} = <%= inspect context.alias %>.delete_<%= schema.singular %>(<%= schema.singular %>)
{:noreply, assign(socket, :<%= schema.collection %>, list_<%=schema.plural %>())}
end
defp list_<%= schema.plural %> do
<%= inspect context.alias %>.list_<%= schema.plural %>()
end
end

View file

@ -0,0 +1,35 @@
h1 do: "Listing <%= schema.human_plural %>"
if @live_action in [:new, :edit] do
live_modal @socket, <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent,
id: @<%= schema.singular %>.id || :new,
title: @page_title,
action: @live_action,
<%= schema.singular %>: @<%= schema.singular %>,
return_to: Routes.<%= schema.route_helper %>_index_path(@socket, :index)
end
table do
thead do
tr do
<%= for {k, _} <- schema.attrs do %> th do: "<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>"
<% end %>
th
end
end
tbody id: "<%= schema.plural %>" do
for <%= schema.singular %> <- @<%= schema.collection %> do
tr id: "<%= schema.singular %>-<%%= <%= schema.singular %>.id %>" do
<%= for {k, _} <- schema.attrs do %> td do: <%= schema.singular %>.<%= k %>
<% end %>
td do
span do: live_redirect "Show", to: Routes.<%= schema.route_helper %>_show_path(@socket, :show, <%= schema.singular %>)
span do: live_patch "Edit", to: Routes.<%= schema.route_helper %>_index_path(@socket, :edit, <%= schema.singular %>)
span do: link "Delete", to: "#", phx_click: "delete", phx_value_id: <%= schema.singular %>.id, data: [confirm: "Are you sure?"]
end
end
end
end
end
span do: live_patch "New <%= schema.human_singular %>", to: Routes.<%= schema.route_helper %>_index_path(@socket, :new)

View file

@ -0,0 +1,23 @@
defmodule <%= inspect context.web_module %>.LiveHelpers do
import Phoenix.LiveView.Helpers
@doc """
Renders a component inside the `<%= inspect context.web_module %>.ModalComponent` component.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<%%= live_modal @socket, <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent,
id: @<%= schema.singular %>.id || :new,
action: @live_action,
<%= schema.singular %>: @<%= schema.singular %>,
return_to: Routes.<%= schema.singular %>_index_path(@socket, :index) %>
"""
def live_modal(socket, component, opts) do
path = Keyword.fetch!(opts, :return_to)
modal_opts = [id: :modal, return_to: path, component: component, opts: opts]
live_component(socket, <%= inspect context.web_module %>.ModalComponent, modal_opts)
end
end

View file

@ -0,0 +1,110 @@
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>LiveTest do
use <%= inspect context.web_module %>.ConnCase
import Phoenix.LiveViewTest
import <%= inspect context.module %>Fixtures
@create_attrs <%= inspect schema.params.create %>
@update_attrs <%= inspect schema.params.update %>
@invalid_attrs <%= inspect for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %>
defp create_<%= schema.singular %>(_) do
<%= schema.singular %> = <%= schema.singular %>_fixture()
%{<%= schema.singular %>: <%= schema.singular %>}
end
describe "Index" do
setup [:create_<%= schema.singular %>]
test "lists all <%= schema.plural %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
{:ok, _index_live, html} = live(conn, Routes.<%= schema.route_helper %>_index_path(conn, :index))
assert html =~ "Listing <%= schema.human_plural %>"<%= if schema.string_attr do %>
assert html =~ <%= schema.singular %>.<%= schema.string_attr %><% end %>
end
test "saves new <%= schema.singular %>", %{conn: conn} do
{:ok, index_live, _html} = live(conn, Routes.<%= schema.route_helper %>_index_path(conn, :index))
assert index_live |> element("a", "New <%= schema.human_singular %>") |> render_click() =~
"New <%= schema.human_singular %>"
assert_patch(index_live, Routes.<%= schema.route_helper %>_index_path(conn, :new))
assert index_live
|> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs)
|> render_change() =~ "can&apos;t be blank"
{:ok, _, html} =
index_live
|> form("#<%= schema.singular %>-form", <%= schema.singular %>: @create_attrs)
|> render_submit()
|> follow_redirect(conn, Routes.<%= schema.route_helper %>_index_path(conn, :index))
assert html =~ "<%= schema.human_singular %> created successfully"<%= if schema.string_attr do %>
assert html =~ "some <%= schema.string_attr %>"<% end %>
end
test "updates <%= schema.singular %> in listing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
{:ok, index_live, _html} = live(conn, Routes.<%= schema.route_helper %>_index_path(conn, :index))
assert index_live |> element("#<%= schema.singular %>-#{<%= schema.singular %>.id} a", "Edit") |> render_click() =~
"Edit <%= schema.human_singular %>"
assert_patch(index_live, Routes.<%= schema.route_helper %>_index_path(conn, :edit, <%= schema.singular %>))
assert index_live
|> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs)
|> render_change() =~ "can&apos;t be blank"
{:ok, _, html} =
index_live
|> form("#<%= schema.singular %>-form", <%= schema.singular %>: @update_attrs)
|> render_submit()
|> follow_redirect(conn, Routes.<%= schema.route_helper %>_index_path(conn, :index))
assert html =~ "<%= schema.human_singular %> updated successfully"<%= if schema.string_attr do %>
assert html =~ "some updated <%= schema.string_attr %>"<% end %>
end
test "deletes <%= schema.singular %> in listing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
{:ok, index_live, _html} = live(conn, Routes.<%= schema.route_helper %>_index_path(conn, :index))
assert index_live |> element("#<%= schema.singular %>-#{<%= schema.singular %>.id} a", "Delete") |> render_click()
refute has_element?(index_live, "#<%= schema.singular %>-#{<%= schema.singular %>.id}")
end
end
describe "Show" do
setup [:create_<%= schema.singular %>]
test "displays <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
{:ok, _show_live, html} = live(conn, Routes.<%= schema.route_helper %>_show_path(conn, :show, <%= schema.singular %>))
assert html =~ "Show <%= schema.human_singular %>"<%= if schema.string_attr do %>
assert html =~ <%= schema.singular %>.<%= schema.string_attr %><% end %>
end
test "updates <%= schema.singular %> within modal", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
{:ok, show_live, _html} = live(conn, Routes.<%= schema.route_helper %>_show_path(conn, :show, <%= schema.singular %>))
assert show_live |> element("a", "Edit") |> render_click() =~
"Edit <%= schema.human_singular %>"
assert_patch(show_live, Routes.<%= schema.route_helper %>_show_path(conn, :edit, <%= schema.singular %>))
assert show_live
|> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs)
|> render_change() =~ "can&apos;t be blank"
{:ok, _, html} =
show_live
|> form("#<%= schema.singular %>-form", <%= schema.singular %>: @update_attrs)
|> render_submit()
|> follow_redirect(conn, Routes.<%= schema.route_helper %>_show_path(conn, :show, <%= schema.singular %>))
assert html =~ "<%= schema.human_singular %> updated successfully"<%= if schema.string_attr do %>
assert html =~ "some updated <%= schema.string_attr %>"<% end %>
end
end
end

View file

@ -0,0 +1,27 @@
defmodule <%= inspect context.web_module %>.ModalComponent do
use <%= inspect context.web_module %>, :live_component
@impl true
def render(assigns) do
live_temple do
div id: @id,
class: "phx-modal",
phx_capture_click: "close",
phx_window_keydown: "close",
phx_key: "escape",
phx_target: "##{@id}",
phx_page_loading: true do
div class: "phx-modal-content" do
live_patch raw("&times"), to: @return_to, class: "phx-modal-close"
live_component @socket, @component, @opts
end
end
end
end
@impl true
def handle_event("close", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
end

View file

@ -0,0 +1,21 @@
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Show do
use <%= inspect context.web_module %>, :live_view
alias <%= inspect context.module %>
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:<%= schema.singular %>, <%= inspect context.alias %>.get_<%= schema.singular %>!(id))}
end
defp page_title(:show), do: "Show <%= schema.human_singular %>"
defp page_title(:edit), do: "Edit <%= schema.human_singular %>"
end

View file

@ -0,0 +1,22 @@
h1 do: "Show <%= schema.human_singular %>"
if @live_action in [:edit] do
live_modal @socket, <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent,
id: @<%= schema.singular %>.id,
title: @page_title,
action: @live_action,
<%= schema.singular %>: @<%= schema.singular %>,
return_to: Routes.<%= schema.route_helper %>_show_path(@socket, :show, @<%= schema.singular %>)
end
ul do
<%= for {k, _} <- schema.attrs do %>
li do
strong do: "<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>:"
@<%= schema.singular %>.<%= k %>
end
<% end %>
end
span do: live_patch "Edit", to: Routes.<%= schema.route_helper %>_show_path(@socket, :edit, @<%= schema.singular %>), class: "button"
span do: live_redirect "Back", to: Routes.<%= schema.route_helper %>_index_path(@socket, :index)