4 | <%= @inner_content %>
5 |
6 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "skip_files": [
3 | "test/",
4 | "lib/live_view_todo/application.ex",
5 | "lib/live_view_todo_web.ex",
6 | "lib/live_view_todo_web/views/error_helpers.ex",
7 | "lib/live_view_todo_web/telemetry.ex",
8 | "lib/live_view_todo_web/channels/user_socket.ex"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/lib/live_view_todo.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo do
2 | @moduledoc """
3 | LiveViewTodo keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/test/live_view_todo_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.LayoutViewTest do
2 | use LiveViewTodoWeb.ConnCase, async: true
3 |
4 | # When testing helpers, you may want to import Phoenix.HTML and
5 | # use functions such as safe_to_string() to convert the helper
6 | # result into an HTML string.
7 | # import Phoenix.HTML
8 | end
9 |
--------------------------------------------------------------------------------
/elixir_buildpack.config:
--------------------------------------------------------------------------------
1 | # Elixir version
2 | elixir_version=1.12.3
3 |
4 | # Erlang version
5 | # available versions https://github.com/HashNuke/heroku-buildpack-elixir-otp-builds/blob/master/otp-versions
6 | erlang_version=23.3.2
7 |
8 | # always_rebuild=true
9 | # build assets
10 | hook_post_compile="eval mix assets.deploy && rm -f _build/esbuild"
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170606070700_create_items.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.Repo.Migrations.CreateItems do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:items) do
6 | add :text, :string
7 | add :person_id, :integer
8 | add :status, :integer, default: 0
9 |
10 | timestamps()
11 | end
12 |
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # TodoApi.Repo.insert!(%TodoApi.SomeModel{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/test/live_view_todo_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ErrorViewTest do
2 | use LiveViewTodoWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(LiveViewTodoWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(LiveViewTodoWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ErrorView do
2 | use LiveViewTodoWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.html", _assigns) do
7 | # "Internal Server Error"
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.html" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | Phoenix.Controller.status_message_from_template(template)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | config :live_view_todo, LiveViewTodo.Repo,
9 | database: Path.expand("../live_view_todo_test.db", Path.dirname(__ENV__.file)),
10 | pool_size: 5,
11 | pool: Ecto.Adapters.SQL.Sandbox
12 |
13 | # We don't run a server during test. If one is required,
14 | # you can enable the server option below.
15 | config :live_view_todo, LiveViewTodoWeb.Endpoint,
16 | http: [port: 4002],
17 | server: false
18 |
19 | # Print only warnings and errors during test
20 | config :logger, level: :warning
21 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/templates/layout/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= csrf_meta_tag() %>
8 | <%= live_title_tag(assigns[:page_title] || "LiveViewTodo", suffix: " · Phoenix Framework") %>
9 |
10 |
17 |
18 |
19 | <%= @inner_content %>
20 |
21 |
22 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import LiveViewTodoWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :live_view_todo
24 | end
25 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/live/page_live.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Todos
4 |
15 |
16 |
17 |
18 |
19 |
20 | <.live_component
21 | module={LiveViewTodoWeb.ItemComponent}
22 | id="cpn"
23 | items={@items}
24 | editing={@editing}
25 | />
26 |
27 |
28 | <.live_component
29 | module={LiveViewTodoWeb.ToolbarComponent}
30 | id="toolbar"
31 | items={@items}
32 | tab={@tab}
33 | />
34 |
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | live_view_todo-*.tar
24 |
25 | # If NPM crashes, it generates a log, let's ignore it too.
26 | npm-debug.log
27 |
28 | # The directory NPM downloads your dependencies sources to.
29 | /assets/node_modules/
30 |
31 |
32 | # Since we are building assets from assets/,
33 | # we ignore priv/static. You may want to comment
34 | # this depending on your deployment strategy.
35 | /priv/static/assets
36 |
37 | # Ignore digested assets cache.
38 | /priv/static/cache_manifest.json
39 | /priv/static
40 | .DS_Store
--------------------------------------------------------------------------------
/lib/live_view_todo/application.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | children = [
10 | # Start the Ecto repository
11 | LiveViewTodo.Repo,
12 | # Start the Telemetry supervisor
13 | LiveViewTodoWeb.Telemetry,
14 | # Start the PubSub system
15 | {Phoenix.PubSub, name: LiveViewTodo.PubSub},
16 | # Start the Endpoint (http/https)
17 | LiveViewTodoWeb.Endpoint
18 | # Start a worker by calling: LiveViewTodo.Worker.start_link(arg)
19 | # {LiveViewTodo.Worker, arg}
20 | ]
21 |
22 | # See https://hexdocs.pm/elixir/Supervisor.html
23 | # for other strategies and supported options
24 | opts = [strategy: :one_for_one, name: LiveViewTodo.Supervisor]
25 | Supervisor.start_link(children, opts)
26 | end
27 |
28 | # Tell Phoenix to update the endpoint configuration
29 | # whenever the application is updated.
30 | def config_change(changed, _new, removed) do
31 | LiveViewTodoWeb.Endpoint.config_change(changed, removed)
32 | :ok
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/config/prod.secret.exs:
--------------------------------------------------------------------------------
1 | # In this file, we load production configuration and secrets
2 | # from environment variables. You can also hardcode secrets,
3 | # although such is generally not recommended and you have to
4 | # remember to add this file to your .gitignore.
5 | import Mix.Config
6 |
7 | database_url =
8 | System.get_env("DATABASE_URL") ||
9 | raise """
10 | environment variable DATABASE_URL is missing.
11 | For example: ecto://USER:PASS@HOST/DATABASE
12 | """
13 |
14 | config :live_view_todo, LiveViewTodo.Repo,
15 | ssl: true,
16 | url: database_url,
17 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
18 |
19 | secret_key_base =
20 | System.get_env("SECRET_KEY_BASE") ||
21 | raise """
22 | environment variable SECRET_KEY_BASE is missing.
23 | You can generate one by calling: mix phx.gen.secret
24 | """
25 |
26 | config :live_view_todo, LiveViewTodoWeb.Endpoint,
27 | load_from_system_env: true,
28 | http: [port: {:system, "PORT"}],
29 | url: [scheme: "https", host: "liveview-todo.herokuapp.com", port: 443],
30 | force_ssl: [rewrite_on: [:x_forwarded_proto]],
31 | cache_static_manifest: "priv/static/cache_manifest.json",
32 | secret_key_base: secret_key_base
33 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: mix
4 | directory: "/"
5 | schedule:
6 | interval: weekly
7 | time: "17:00"
8 | timezone: Europe/London
9 | ignore:
10 | # ignore all patch updates in dev dependencies ref: github.com/dwyl/technology-stack/issues/126 [alphabetical list]
11 | - dependency-name: "credo"
12 | update-types: ["version-update:semver-patch"]
13 | - dependency-name: "dialyxir"
14 | update-types: ["version-update:semver-patch"]
15 | - dependency-name: "excoveralls"
16 | update-types: ["version-update:semver-patch"]
17 | - dependency-name: "ex_doc"
18 | update-types: ["version-update:semver-patch"]
19 | - dependency-name: "esbuild"
20 | update-types: ["version-update:semver-patch"]
21 | - dependency-name: "floki"
22 | update-types: ["version-update:semver-patch"]
23 | - dependency-name: "gettext"
24 | update-types: ["version-update:semver-patch"]
25 | - dependency-name: "mock"
26 | update-types: ["version-update:semver-patch"]
27 | - dependency-name: "phoenix_live_dashboard"
28 | update-types: ["version-update:semver-patch"]
29 | - dependency-name: "phoenix_live_reload"
30 | update-types: ["version-update:semver-patch"]
31 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", LiveViewTodoWeb.RoomChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 | @impl true
19 | def connect(_params, socket, _connect_info) do
20 | {:ok, socket}
21 | end
22 |
23 | # Socket id's are topics that allow you to identify all sockets for a given user:
24 | #
25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
26 | #
27 | # Would allow you to broadcast a "disconnect" event and terminate
28 | # all active sockets and channels for a given user:
29 | #
30 | # LiveViewTodoWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
31 | #
32 | # Returning `nil` makes this socket anonymous.
33 | @impl true
34 | def id(_socket), do: nil
35 | end
36 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use LiveViewTodoWeb.ChannelCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with channels
23 | import Phoenix.ChannelTest
24 | import LiveViewTodoWeb.ChannelCase
25 |
26 | # The default endpoint for testing
27 | @endpoint LiveViewTodoWeb.Endpoint
28 | end
29 | end
30 |
31 | setup tags do
32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveViewTodo.Repo)
33 |
34 | unless tags[:async] do
35 | Ecto.Adapters.SQL.Sandbox.mode(LiveViewTodo.Repo, {:shared, self()})
36 | end
37 |
38 | :ok
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.Router do
2 | use LiveViewTodoWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_live_flash
8 | plug :put_root_layout, {LiveViewTodoWeb.LayoutView, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | # pipeline :api do
14 | # plug :accepts, ["json"]
15 | # end
16 |
17 | scope "/", LiveViewTodoWeb do
18 | pipe_through :browser
19 |
20 | live "/", PageLive
21 | end
22 |
23 | # Other scopes may use custom stacks.
24 | # scope "/api", LiveViewTodoWeb do
25 | # pipe_through :api
26 | # end
27 |
28 | # Enables LiveDashboard only for development
29 | #
30 | # If you want to use the LiveDashboard in production, you should put
31 | # it behind authentication and allow only admins to access it.
32 | # If your application does not have an admins-only section yet,
33 | # you can use Plug.BasicAuth to set up some basic authentication
34 | # as long as you are also using SSL (which you should anyway).
35 | # if Mix.env() in [:dev, :test] do
36 | # import Phoenix.LiveDashboard.Router
37 |
38 | # scope "/" do
39 | # pipe_through :browser
40 | # live_dashboard "/dashboard", metrics: LiveViewTodoWeb.Telemetry
41 | # end
42 | # end
43 | end
44 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use LiveViewTodoWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with connections
23 | import Plug.Conn
24 | import Phoenix.ConnTest
25 | import LiveViewTodoWeb.ConnCase
26 |
27 | alias LiveViewTodoWeb.Router.Helpers, as: Routes
28 |
29 | # The default endpoint for testing
30 | @endpoint LiveViewTodoWeb.Endpoint
31 | end
32 | end
33 |
34 | setup tags do
35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveViewTodo.Repo)
36 |
37 | unless tags[:async] do
38 | Ecto.Adapters.SQL.Sandbox.mode(LiveViewTodo.Repo, {:shared, self()})
39 | end
40 |
41 | {:ok, conn: Phoenix.ConnTest.build_conn()}
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :live_view_todo,
11 | ecto_repos: [LiveViewTodo.Repo]
12 |
13 | # Configures the endpoint
14 | config :live_view_todo, LiveViewTodoWeb.Endpoint,
15 | url: [host: "localhost"],
16 | secret_key_base: "mve+k9ilc/5gZAQOxZ2kc5VRJX3JwxXoyLyteev/xpDLavBZ5XP9JqehJs96PGwB",
17 | render_errors: [view: LiveViewTodoWeb.ErrorView, accepts: ~w(html json), layout: false],
18 | pubsub_server: LiveViewTodo.PubSub,
19 | live_view: [signing_salt: "gJUPwnsw"]
20 |
21 | # Configures Elixir's Logger
22 | config :logger, :console,
23 | format: "$time $metadata[$level] $message\n",
24 | metadata: [:request_id]
25 |
26 | # Use Jason for JSON parsing in Phoenix
27 | config :phoenix, :json_library, Jason
28 |
29 | config :esbuild,
30 | version: "0.12.18",
31 | default: [
32 | args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets),
33 | cd: Path.expand("../assets", __DIR__),
34 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
35 | ]
36 |
37 | # Import environment specific config. This must remain at the bottom
38 | # of this file so it overrides the configuration defined above.
39 | import_config "#{Mix.env()}.exs"
40 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Elixir CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 |
11 | # Build and testing
12 | build:
13 | name: Build and test
14 | runs-on: ubuntu-latest
15 | services:
16 | postgres:
17 | image: postgres:12
18 | ports: ['5432:5432']
19 | env:
20 | POSTGRES_PASSWORD: postgres
21 | options: >-
22 | --health-cmd pg_isready
23 | --health-interval 10s
24 | --health-timeout 5s
25 | --health-retries 5
26 | strategy:
27 | matrix:
28 | otp: ['25.1.2']
29 | elixir: ['1.14.2']
30 | steps:
31 | - uses: actions/checkout@v2
32 | - name: Set up Elixir
33 | uses: erlef/setup-beam@v1
34 | with:
35 | otp-version: ${{ matrix.otp }}
36 | elixir-version: ${{ matrix.elixir }}
37 | - name: Restore deps and _build cache
38 | uses: actions/cache@v3
39 | with:
40 | path: |
41 | deps
42 | _build
43 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
44 | restore-keys: |
45 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}
46 | - name: Install dependencies
47 | run: mix deps.get
48 | - name: Check code is formatted
49 | run: mix format --check-formatted
50 | - name: Run Tests
51 | run: mix coveralls.json
52 | env:
53 | MIX_ENV: test
54 | - name: Upload coverage to Codecov
55 | uses: codecov/codecov-action@v1
56 |
57 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/live/page_live.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.PageLive do
2 | use LiveViewTodoWeb, :live_view
3 | alias LiveViewTodo.Item
4 |
5 | @topic "live"
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | # subscribe to the channel
10 | if connected?(socket), do: LiveViewTodoWeb.Endpoint.subscribe(@topic)
11 | {:ok, assign(socket, items: Item.list_items(), editing: nil, tab: "all")}
12 | end
13 |
14 | @impl true
15 | def handle_event("create", %{"text" => text}, socket) do
16 | Item.create_item(%{text: text})
17 | socket = assign(socket, items: Item.list_items(), active: %Item{})
18 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
19 | {:noreply, socket}
20 | end
21 |
22 | @impl true
23 | def handle_event("clear-completed", _data, socket) do
24 | Item.clear_completed()
25 | items = Item.list_items()
26 | {:noreply, assign(socket, items: items)}
27 | end
28 |
29 | @impl true
30 | def handle_info(%{event: "update", payload: %{items: items}}, socket) do
31 | {:noreply, assign(socket, items: items)}
32 | end
33 |
34 | @impl true
35 | def handle_params(params, _url, socket) do
36 | items = Item.list_items()
37 |
38 | case params["filter_by"] do
39 | "completed" ->
40 | completed = Enum.filter(items, &(&1.status == 1))
41 | {:noreply, assign(socket, items: completed, tab: "completed")}
42 |
43 | "active" ->
44 | active = Enum.filter(items, &(&1.status == 0))
45 | {:noreply, assign(socket, items: active, tab: "active")}
46 |
47 | _ ->
48 | {:noreply, assign(socket, items: items, tab: "all")}
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:span, translate_error(error),
14 | class: "invalid-feedback",
15 | phx_feedback_for: input_id(form, field)
16 | )
17 | end)
18 | end
19 |
20 | @doc """
21 | Translates an error message using gettext.
22 | """
23 | def translate_error({msg, opts}) do
24 | # When using gettext, we typically pass the strings we want
25 | # to translate as a static argument:
26 | #
27 | # # Translate "is invalid" in the "errors" domain
28 | # dgettext("errors", "is invalid")
29 | #
30 | # # Translate the number of files with plural rules
31 | # dngettext("errors", "1 file", "%{count} files", count)
32 | #
33 | # Because the error messages we show in our forms and APIs
34 | # are defined inside Ecto, we need to translate them dynamically.
35 | # This requires us to call the Gettext module passing our gettext
36 | # backend as first argument.
37 | #
38 | # Note we use the "errors" domain, which means translations
39 | # should be written to the errors.po file. The :count option is
40 | # set by Ecto and indicates we should also apply plural rules.
41 | if count = opts[:count] do
42 | Gettext.dngettext(LiveViewTodoWeb.Gettext, "errors", msg, msg, count, opts)
43 | else
44 | Gettext.dgettext(LiveViewTodoWeb.Gettext, "errors", msg, opts)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/live/toolbar_component.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ToolbarComponent do
2 | use LiveViewTodoWeb, :live_component
3 |
4 | attr(:tab, :string, default: "all")
5 | attr(:items, :list, default: [])
6 |
7 | def render(assigns) do
8 | ~H"""
9 |
51 | """
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use LiveViewTodo.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | alias LiveViewTodo.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import LiveViewTodo.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveViewTodo.Repo)
32 |
33 | unless tags[:async] do
34 | Ecto.Adapters.SQL.Sandbox.mode(LiveViewTodo.Repo, {:shared, self()})
35 | end
36 |
37 | :ok
38 | end
39 |
40 | @doc """
41 | A helper that transforms changeset errors into a map of messages.
42 |
43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
44 | assert "password is too short" in errors_on(changeset).password
45 | assert %{password: ["password is too short"]} = errors_on(changeset)
46 |
47 | """
48 | def errors_on(changeset) do
49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
50 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
52 | end)
53 | end)
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :live_view_todo
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_live_view_todo_key",
10 | signing_salt: "J0v1PA6M"
11 | ]
12 |
13 | socket "/socket", LiveViewTodoWeb.UserSocket,
14 | websocket: true,
15 | longpoll: false
16 |
17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
18 |
19 | # Serve at "/" the static files from "priv/static" directory.
20 | #
21 | # You should set gzip to true if you are running phx.digest
22 | # when deploying your static files in production.
23 | plug Plug.Static,
24 | at: "/",
25 | from: :live_view_todo,
26 | gzip: false,
27 | only: ~w(assets fonts images favicon.ico robots.txt)
28 |
29 | # Code reloading can be explicitly enabled under the
30 | # :code_reloader configuration of your endpoint.
31 | if code_reloading? do
32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
33 | plug Phoenix.LiveReloader
34 | plug Phoenix.CodeReloader
35 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :live_view_todo
36 | end
37 |
38 | plug Phoenix.LiveDashboard.RequestLogger,
39 | param_key: "request_logger",
40 | cookie_key: "request_logger"
41 |
42 | plug Plug.RequestId
43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
44 |
45 | plug Plug.Parsers,
46 | parsers: [:urlencoded, :multipart, :json],
47 | pass: ["*/*"],
48 | json_decoder: Phoenix.json_library()
49 |
50 | plug Plug.MethodOverride
51 | plug Plug.Head
52 | plug Plug.Session, @session_options
53 | plug LiveViewTodoWeb.Router
54 | end
55 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application css. */
2 | @import "./todomvc-app.css";
3 |
4 | /* LiveView specific classes for your customizations */
5 | .phx-no-feedback.invalid-feedback,
6 | .phx-no-feedback .invalid-feedback {
7 | display: none;
8 | }
9 |
10 | .phx-click-loading {
11 | opacity: 0.5;
12 | transition: opacity 1s ease-out;
13 | }
14 |
15 | .phx-disconnected {
16 | cursor: wait;
17 | }
18 | .phx-disconnected * {
19 | pointer-events: none;
20 | }
21 |
22 | .phx-modal {
23 | opacity: 1 !important;
24 | position: fixed;
25 | z-index: 1;
26 | left: 0;
27 | top: 0;
28 | width: 100%;
29 | height: 100%;
30 | overflow: auto;
31 | background-color: rgb(0, 0, 0);
32 | background-color: rgba(0, 0, 0, 0.4);
33 | }
34 |
35 | .phx-modal-content {
36 | background-color: #fefefe;
37 | margin: 15% auto;
38 | padding: 20px;
39 | border: 1px solid #888;
40 | width: 80%;
41 | }
42 |
43 | .phx-modal-close {
44 | color: #aaa;
45 | float: right;
46 | font-size: 28px;
47 | font-weight: bold;
48 | }
49 |
50 | .phx-modal-close:hover,
51 | .phx-modal-close:focus {
52 | color: black;
53 | text-decoration: none;
54 | cursor: pointer;
55 | }
56 |
57 | /* Alerts and form errors */
58 | .alert {
59 | padding: 15px;
60 | margin-bottom: 20px;
61 | border: 1px solid transparent;
62 | border-radius: 4px;
63 | }
64 | .alert-info {
65 | color: #31708f;
66 | background-color: #d9edf7;
67 | border-color: #bce8f1;
68 | }
69 | .alert-warning {
70 | color: #8a6d3b;
71 | background-color: #fcf8e3;
72 | border-color: #faebcc;
73 | }
74 | .alert-danger {
75 | color: #a94442;
76 | background-color: #f2dede;
77 | border-color: #ebccd1;
78 | }
79 | .alert p {
80 | margin-bottom: 0;
81 | }
82 | .alert:empty {
83 | display: none;
84 | }
85 | .invalid-feedback {
86 | color: #a94442;
87 | display: block;
88 | margin: -1rem 0 2rem;
89 | }
90 |
--------------------------------------------------------------------------------
/test/live_view_todo/item_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.ItemTest do
2 | use LiveViewTodo.DataCase
3 | alias LiveViewTodo.Item
4 |
5 | describe "items" do
6 | @valid_attrs %{text: "some text", person_id: 1}
7 | @update_attrs %{text: "some updated text", status: 1}
8 | @invalid_attrs %{text: nil}
9 |
10 | def item_fixture(attrs \\ %{}) do
11 | {:ok, item} =
12 | attrs
13 | |> Enum.into(@valid_attrs)
14 | |> Item.create_item()
15 |
16 | item
17 | end
18 |
19 | test "get_item!/1 returns the item with given id" do
20 | item = item_fixture(@valid_attrs)
21 | assert Item.get_item!(item.id) == item
22 | end
23 |
24 | test "create_item/1 with valid data creates a item" do
25 | assert {:ok, %Item{} = item} = Item.create_item(@valid_attrs)
26 | assert item.text == "some text"
27 |
28 | inserted_item = List.first(Item.list_items())
29 | assert inserted_item.text == @valid_attrs.text
30 | end
31 |
32 | test "create_item/1 with invalid data returns error changeset" do
33 | assert {:error, %Ecto.Changeset{}} = Item.create_item(@invalid_attrs)
34 | end
35 |
36 | test "list_items/0 returns a list of todo items stored in the DB" do
37 | item1 = item_fixture()
38 | item2 = item_fixture()
39 | items = Item.list_items()
40 | assert Enum.member?(items, item1)
41 | assert Enum.member?(items, item2)
42 | end
43 |
44 | test "update_item/2 with valid data updates the item" do
45 | item = item_fixture()
46 | assert {:ok, %Item{} = item} = Item.update_item(item, @update_attrs)
47 | assert item.text == "some updated text"
48 | end
49 |
50 | test "delete_item/1 soft-deltes an item" do
51 | item = item_fixture()
52 | assert {:ok, %Item{} = deleted_item} = Item.delete_item(item.id)
53 | assert deleted_item.status == 2
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.stop.duration",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.router_dispatch.stop.duration",
29 | tags: [:route],
30 | unit: {:native, :millisecond}
31 | ),
32 |
33 | # Database Metrics
34 | summary("live_view_todo.repo.query.total_time", unit: {:native, :millisecond}),
35 | summary("live_view_todo.repo.query.decode_time", unit: {:native, :millisecond}),
36 | summary("live_view_todo.repo.query.query_time", unit: {:native, :millisecond}),
37 | summary("live_view_todo.repo.query.queue_time", unit: {:native, :millisecond}),
38 | summary("live_view_todo.repo.query.idle_time", unit: {:native, :millisecond}),
39 |
40 | # VM Metrics
41 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
42 | summary("vm.total_run_queue_lengths.total"),
43 | summary("vm.total_run_queue_lengths.cpu"),
44 | summary("vm.total_run_queue_lengths.io")
45 | ]
46 | end
47 |
48 | defp periodic_measurements do
49 | [
50 | # A module, function and arguments to be invoked periodically.
51 | # This function must call :telemetry.execute/3 and a metric must be added above.
52 | # {LiveViewTodoWeb, :count_users, []}
53 | ]
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Mix.Config
2 |
3 | # Configure your database
4 | config :live_view_todo, LiveViewTodo.Repo,
5 | database: Path.expand("../live_view_todo_dev.db", Path.dirname(__ENV__.file)),
6 | pool_size: 5,
7 | stacktrace: true,
8 | show_sensitive_data_on_connection_error: true
9 |
10 | # For development, we disable any cache and enable
11 | # debugging and code reloading.
12 | #
13 | # The watchers configuration can be used to run external
14 | # watchers to your application. For example, we use it
15 | # with webpack to recompile .js and .css sources.
16 | config :live_view_todo, LiveViewTodoWeb.Endpoint,
17 | http: [port: 4000],
18 | debug_errors: true,
19 | code_reloader: true,
20 | check_origin: false,
21 | watchers: [
22 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
23 | ]
24 |
25 | # ## SSL Support
26 | #
27 | # In order to use HTTPS in development, a self-signed
28 | # certificate can be generated by running the following
29 | # Mix task:
30 | #
31 | # mix phx.gen.cert
32 | #
33 | # Note that this task requires Erlang/OTP 20 or later.
34 | # Run `mix help phx.gen.cert` for more information.
35 | #
36 | # The `http:` config above can be replaced with:
37 | #
38 | # https: [
39 | # port: 4001,
40 | # cipher_suite: :strong,
41 | # keyfile: "priv/cert/selfsigned_key.pem",
42 | # certfile: "priv/cert/selfsigned.pem"
43 | # ],
44 | #
45 | # If desired, both `http:` and `https:` keys can be
46 | # configured to run both http and https servers on
47 | # different ports.
48 |
49 | # Watch static and templates for browser reloading.
50 | config :live_view_todo, LiveViewTodoWeb.Endpoint,
51 | live_reload: [
52 | patterns: [
53 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
54 | ~r"priv/gettext/.*(po)$",
55 | ~r"lib/live_view_todo_web/(live|views)/.*(ex)$",
56 | ~r"lib/live_view_todo_web/templates/.*(eex)$"
57 | ]
58 | ]
59 |
60 | # Do not include metadata nor timestamps in development logs
61 | config :logger, :console, format: "[$level] $message\n"
62 |
63 | # Set a higher stacktrace during development. Avoid configuring such
64 | # in production as building large stacktraces may be expensive.
65 | config :phoenix, :stacktrace_depth, 20
66 |
67 | # Initialize plugs at runtime for faster development compilation
68 | config :phoenix, :plug_init_mode, :runtime
69 |
--------------------------------------------------------------------------------
/lib/live_view_todo/item.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.Item do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | import Ecto.Query
5 | alias LiveViewTodo.Repo
6 | alias __MODULE__
7 |
8 | schema "items" do
9 | field :person_id, :integer
10 | field :status, :integer, default: 0
11 | field :text, :string
12 |
13 | timestamps()
14 | end
15 |
16 | @doc false
17 | def changeset(item, attrs) do
18 | item
19 | |> cast(attrs, [:text, :person_id, :status])
20 | |> validate_required([:text])
21 | end
22 |
23 | @doc """
24 | Creates a item.
25 |
26 | ## Examples
27 |
28 | iex> create_item(%{text: "Learn LiveView"})
29 | {:ok, %Item{}}
30 |
31 | iex> create_item(%{text: nil})
32 | {:error, %Ecto.Changeset{}}
33 |
34 | """
35 | def create_item(attrs \\ %{}) do
36 | %Item{}
37 | |> changeset(attrs)
38 | |> Repo.insert()
39 | end
40 |
41 | @doc """
42 | Gets a single item.
43 |
44 | Raises `Ecto.NoResultsError` if the Item does not exist.
45 |
46 | ## Examples
47 |
48 | iex> get_item!(123)
49 | %Item{}
50 |
51 | iex> get_item!(456)
52 | ** (Ecto.NoResultsError)
53 |
54 | """
55 | def get_item!(id), do: Repo.get!(Item, id)
56 |
57 | @doc """
58 | Returns the list of items.
59 |
60 | ## Examples
61 |
62 | iex> list_items()
63 | [%Item{}, ...]
64 |
65 | """
66 | def list_items do
67 | Item
68 | |> order_by(desc: :inserted_at)
69 | |> where([a], is_nil(a.status) or a.status != 2)
70 | |> Repo.all()
71 | end
72 |
73 | @doc """
74 | Updates a item.
75 |
76 | ## Examples
77 |
78 | iex> update_item(item, %{field: new_value})
79 | {:ok, %Item{}}
80 |
81 | iex> update_item(item, %{field: bad_value})
82 | {:error, %Ecto.Changeset{}}
83 |
84 | """
85 | def update_item(%Item{} = item, attrs) do
86 | item
87 | |> Item.changeset(attrs)
88 | |> Repo.update()
89 | end
90 |
91 | # "soft" delete
92 | def delete_item(id) do
93 | get_item!(id)
94 | |> Item.changeset(%{status: 2})
95 | |> Repo.update()
96 | end
97 |
98 | @doc """
99 | Set status to 2 for item with status 1,
100 | ie delete completed item
101 | """
102 | def clear_completed() do
103 | completed_items = from(i in Item, where: i.status == 1)
104 | Repo.update_all(completed_items, set: [status: 2])
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_format/3
26 | msgid "has invalid format"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_subset/3
30 | msgid "has an invalid entry"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_exclusion/3
34 | msgid "is reserved"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_confirmation/3
38 | msgid "does not match confirmation"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.no_assoc_constraint/3
42 | msgid "is still associated to this entry"
43 | msgstr ""
44 |
45 | msgid "are still associated to this entry"
46 | msgstr ""
47 |
48 | ## From Ecto.Changeset.validate_length/3
49 | msgid "should be %{count} character(s)"
50 | msgid_plural "should be %{count} character(s)"
51 | msgstr[0] ""
52 | msgstr[1] ""
53 |
54 | msgid "should have %{count} item(s)"
55 | msgid_plural "should have %{count} item(s)"
56 | msgstr[0] ""
57 | msgstr[1] ""
58 |
59 | msgid "should be at least %{count} character(s)"
60 | msgid_plural "should be at least %{count} character(s)"
61 | msgstr[0] ""
62 | msgstr[1] ""
63 |
64 | msgid "should have at least %{count} item(s)"
65 | msgid_plural "should have at least %{count} item(s)"
66 | msgstr[0] ""
67 | msgstr[1] ""
68 |
69 | msgid "should be at most %{count} character(s)"
70 | msgid_plural "should be at most %{count} character(s)"
71 | msgstr[0] ""
72 | msgstr[1] ""
73 |
74 | msgid "should have at most %{count} item(s)"
75 | msgid_plural "should have at most %{count} item(s)"
76 | msgstr[0] ""
77 | msgstr[1] ""
78 |
79 | ## From Ecto.Changeset.validate_number/3
80 | msgid "must be less than %{number}"
81 | msgstr ""
82 |
83 | msgid "must be greater than %{number}"
84 | msgstr ""
85 |
86 | msgid "must be less than or equal to %{number}"
87 | msgstr ""
88 |
89 | msgid "must be greater than or equal to %{number}"
90 | msgstr ""
91 |
92 | msgid "must be equal to %{number}"
93 | msgstr ""
94 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This file is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here as no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_format/3
24 | msgid "has invalid format"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_subset/3
28 | msgid "has an invalid entry"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_exclusion/3
32 | msgid "is reserved"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_confirmation/3
36 | msgid "does not match confirmation"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.no_assoc_constraint/3
40 | msgid "is still associated to this entry"
41 | msgstr ""
42 |
43 | msgid "are still associated to this entry"
44 | msgstr ""
45 |
46 | ## From Ecto.Changeset.validate_length/3
47 | msgid "should be %{count} character(s)"
48 | msgid_plural "should be %{count} character(s)"
49 | msgstr[0] ""
50 | msgstr[1] ""
51 |
52 | msgid "should have %{count} item(s)"
53 | msgid_plural "should have %{count} item(s)"
54 | msgstr[0] ""
55 | msgstr[1] ""
56 |
57 | msgid "should be at least %{count} character(s)"
58 | msgid_plural "should be at least %{count} character(s)"
59 | msgstr[0] ""
60 | msgstr[1] ""
61 |
62 | msgid "should have at least %{count} item(s)"
63 | msgid_plural "should have at least %{count} item(s)"
64 | msgstr[0] ""
65 | msgstr[1] ""
66 |
67 | msgid "should be at most %{count} character(s)"
68 | msgid_plural "should be at most %{count} character(s)"
69 | msgstr[0] ""
70 | msgstr[1] ""
71 |
72 | msgid "should have at most %{count} item(s)"
73 | msgid_plural "should have at most %{count} item(s)"
74 | msgstr[0] ""
75 | msgstr[1] ""
76 |
77 | ## From Ecto.Changeset.validate_number/3
78 | msgid "must be less than %{number}"
79 | msgstr ""
80 |
81 | msgid "must be greater than %{number}"
82 | msgstr ""
83 |
84 | msgid "must be less than or equal to %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than or equal to %{number}"
88 | msgstr ""
89 |
90 | msgid "must be equal to %{number}"
91 | msgstr ""
92 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :live_view_todo,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | aliases: aliases(),
13 | deps: deps(),
14 | test_coverage: [tool: ExCoveralls],
15 | preferred_cli_env: [
16 | c: :test,
17 | coveralls: :test,
18 | "coveralls.json": :test,
19 | "coveralls.html": :test
20 | ]
21 | ]
22 | end
23 |
24 | # Configuration for the OTP application.
25 | #
26 | # Type `mix help compile.app` for more information.
27 | def application do
28 | [
29 | mod: {LiveViewTodo.Application, []},
30 | extra_applications: [:logger, :runtime_tools]
31 | ]
32 | end
33 |
34 | # Specifies which paths to compile per environment.
35 | defp elixirc_paths(:test), do: ["lib", "test/support"]
36 | defp elixirc_paths(_), do: ["lib"]
37 |
38 | # Specifies your project dependencies.
39 | #
40 | # Type `mix help deps` for examples and options.
41 | defp deps do
42 | [
43 | {:phoenix, "~> 1.7.0"},
44 | {:phoenix_ecto, "~> 4.4"},
45 | {:ecto_sql, "~> 3.11.0"},
46 | {:ecto_sqlite3, ">= 0.0.0"},
47 | {:phoenix_live_view, "~> 0.18"},
48 | {:phoenix_view, "~> 2.0"},
49 | {:floki, ">= 0.30.0", only: :test},
50 | {:phoenix_html, "~> 3.1"},
51 | {:phoenix_live_reload, "~> 1.2", only: :dev},
52 | {:phoenix_live_dashboard, "~> 0.7"},
53 | {:telemetry_metrics, "~> 0.6"},
54 | {:telemetry_poller, "~> 1.0"},
55 | {:gettext, "~> 0.18"},
56 | {:jason, "~> 1.2"},
57 | {:plug_cowboy, "~> 2.5"},
58 | {:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
59 | # track test coverage: https://github.com/parroty/excoveralls
60 | {:excoveralls, "~> 0.18.0", only: [:test, :dev]}
61 | ]
62 | end
63 |
64 | # Aliases are shortcuts or tasks specific to the current project.
65 | # For example, to install project dependencies and perform other setup tasks, run:
66 | #
67 | # $ mix setup
68 | #
69 | # See the documentation for `Mix` for more info on aliases.
70 | defp aliases do
71 | [
72 | setup: ["deps.get", "ecto.setup"],
73 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
74 | "ecto.reset": ["ecto.drop", "ecto.setup"],
75 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
76 | c: ["coveralls.html"],
77 | s: ["phx.server"],
78 | "assets.deploy": ["esbuild default --minify", "phx.digest"]
79 | ]
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // We import the CSS which is extracted to its own file by esbuild.
2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss).
3 | import "../css/app.css"
4 |
5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
6 | // to get started and then uncomment the line below.
7 | // import "./user_socket.js"
8 |
9 | // You can include dependencies in two ways.
10 | //
11 | // The simplest option is to put them in assets/vendor and
12 | // import them using relative paths:
13 | //
14 | // import "./vendor/some-package.js"
15 | //
16 | // Alternatively, you can `npm install some-package` and import
17 | // them using a path starting with the package name:
18 | //
19 | // import "some-package"
20 | //
21 |
22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
23 | import "phoenix_html"
24 | // Establish Phoenix Socket and LiveView configuration.
25 | import { Socket } from "phoenix"
26 | import { LiveSocket } from "phoenix_live_view"
27 | import topbar from "../vendor/topbar"
28 |
29 | function focusInput(input) {
30 | let end = input.value.length;
31 | input.setSelectionRange(end, end);
32 | input.focus();
33 | }
34 |
35 | let Hooks = {}
36 | Hooks.FocusInputItem = {
37 | mounted() {
38 | focusInput(document.getElementById("update_todo"));
39 | },
40 | updated() {
41 | focusInput(document.getElementById("update_todo"));
42 | }
43 | }
44 |
45 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
46 | let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks })
47 |
48 | // Show progress bar on live navigation and form submits
49 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
50 | window.addEventListener("phx:page-loading-start", info => topbar.show())
51 | window.addEventListener("phx:page-loading-stop", info => topbar.hide())
52 |
53 | // connect if there are any LiveViews on the page
54 | liveSocket.connect()
55 |
56 | // expose liveSocket on window for web console debug logs and latency simulation:
57 | // >> liveSocket.enableDebug()
58 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
59 | // >> liveSocket.disableLatencySim()
60 | window.liveSocket = liveSocket
61 |
62 |
63 | let msg = document.getElementById('msg'); // message input field
64 | let form = document.getElementById('form-msg'); // message input field
65 |
66 | // Reset todo list form input ... this is the simplest way we found ¯\_(ツ)_/¯
67 | document.getElementById('form').addEventListener('submit', function () {
68 | // the setTimeout is required to let phx-submit do it's thing first ...
69 | setTimeout(() => { document.getElementById('new_todo').value = ""; }, 1)
70 | });
71 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use LiveViewTodoWeb, :controller
9 | use LiveViewTodoWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: LiveViewTodoWeb
23 |
24 | import Plug.Conn
25 | import LiveViewTodoWeb.Gettext
26 | alias LiveViewTodoWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View,
33 | root: "lib/live_view_todo_web/templates",
34 | namespace: LiveViewTodoWeb
35 |
36 | # Import convenience functions from controllers
37 | import Phoenix.Controller,
38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
39 |
40 | # Include shared imports and aliases for views
41 | unquote(view_helpers())
42 | end
43 | end
44 |
45 | def live_view do
46 | quote do
47 | use Phoenix.LiveView,
48 | layout: {LiveViewTodoWeb.LayoutView, :live}
49 |
50 | unquote(view_helpers())
51 | end
52 | end
53 |
54 | def live_component do
55 | quote do
56 | use Phoenix.LiveComponent
57 |
58 | unquote(view_helpers())
59 | end
60 | end
61 |
62 | def router do
63 | quote do
64 | use Phoenix.Router
65 |
66 | import Plug.Conn
67 | import Phoenix.Controller
68 | import Phoenix.LiveView.Router
69 | end
70 | end
71 |
72 | def channel do
73 | quote do
74 | use Phoenix.Channel
75 | import LiveViewTodoWeb.Gettext
76 | end
77 | end
78 |
79 | defp view_helpers do
80 | quote do
81 | # Use all HTML functionality (forms, tags, etc)
82 | use Phoenix.HTML
83 |
84 | # Import LiveView helpers (live_render, live_component, live_patch, etc)
85 | import Phoenix.LiveView.Helpers
86 |
87 | # Import basic rendering functionality (render, render_layout, etc)
88 | import Phoenix.View
89 |
90 | import LiveViewTodoWeb.ErrorHelpers
91 | import LiveViewTodoWeb.Gettext
92 | alias LiveViewTodoWeb.Router.Helpers, as: Routes
93 | import Phoenix.Component
94 | end
95 | end
96 |
97 | @doc """
98 | When used, dispatch to the appropriate controller/view/etc.
99 | """
100 | defmacro __using__(which) when is_atom(which) do
101 | apply(__MODULE__, which, [])
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/lib/live_view_todo_web/live/item_component.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodoWeb.ItemComponent do
2 | use LiveViewTodoWeb, :live_component
3 | alias LiveViewTodo.Item
4 |
5 | @topic "live"
6 |
7 | attr(:items, :list, default: [])
8 |
9 | def render(assigns) do
10 | ~H"""
11 |
12 | <%= for item <- @items do %>
13 | <%= if item.id == @editing do %>
14 |
2 |
3 | # Phoenix LiveView Todo List Tutorial
4 |
5 | 
6 | [](https://codecov.io/github/dwyl/phoenix-liveview-todo-list-tutorial?branch=master)
7 | [](https://hex.pm/packages/phoenix_live_view)
8 | [](https://github.com/dwyl/phoenix-liveview-todo-list-tutorial/issues)
9 | [](https://hits.dwyl.io/dwyl/phoenix-liveview-todo-list-tutorial)
10 |
11 | **Build your _second_ App** using **Phoenix LiveView**
12 | and _understand_ how to build real-world apps in **20 minutes** or _less_!
13 |
14 |
21 |
22 |
23 |
24 |
25 | - [Phoenix LiveView Todo List Tutorial](#phoenix-liveview-todo-list-tutorial)
26 | - [Why? 🤷](#why-)
27 | - [What? 💭](#what-)
28 | - [Who? 👤](#who-)
29 | - [Prerequisites: What you Need _Before_ You Start 📝](#prerequisites-what-you-need-before-you-start-)
30 | - [How? 💻](#how-)
31 | - [Step 0: Run the _Finished_ Todo App on your `localhost` 🏃](#step-0-run-the-finished-todo-app-on-your-localhost-)
32 | - [Clone the Repository](#clone-the-repository)
33 | - [_Download_ the Dependencies](#download-the-dependencies)
34 | - [_Run_ the App](#run-the-app)
35 | - [Step 1: Create the App 🆕](#step-1-create-the-app-)
36 | - [Checkpoint 1a: _Run_ the _Tests_!](#checkpoint-1a-run-the-tests)
37 | - [Checkpoint 1b: _Run_ the New Phoenix App!](#checkpoint-1b-run-the-new-phoenix-app)
38 | - [2. Create the TodoMVC UI/UX](#2-create-the-todomvc-uiux)
39 | - [2.1 Create live folder](#21-create-live-folder)
40 | - [2.2 Update the Root Layout](#22-update-the-root-layout)
41 | - [2.3 Create the page_live layout](#23-create-the-page_live-layout)
42 | - [2.4 Update Router and controller](#24-update-router-and-controller)
43 | - [2.5 Save the TodoMVC CSS to `/assets/css`](#25-save-the-todomvc-css-to-assetscss)
44 | - [2.6 Import the `todomvc-app.css` in `app.scss`](#26-import-the-todomvc-appcss-in-appscss)
45 | - [2.7 Update the test](#27-update-the-test)
46 | - [3. Create the Todo List `items` Schema](#3-create-the-todo-list-items-schema)
47 | - [3.1 Add Aliases to `item.ex`](#31-add-aliases-to-itemex)
48 | - [3.2 Create Todo Item CRUD Tests](#32-create-todo-item-crud-tests)
49 | - [3.3 Make the CRUD Tests _Pass_](#33-make-the-crud-tests-pass)
50 | - [4. Handle Todo List `Item` Creation](#4-handle-todo-list-item-creation)
51 | - [5. _Show_ the Created Todo `Items`](#5-show-the-created-todo-items)
52 | - [6. Toggle the State of Todo Items](#6-toggle-the-state-of-todo-items)
53 | - [7. "Delete" a Todo `item`](#7-delete-a-todo-item)
54 | - [8. Editing Todo `item.text`](#8-editing-todo-itemtext)
55 | - [UI enhancement](#ui-enhancement)
56 | - [9. Footer Navigation](#9-footer-navigation)
57 | - [10. Clear Completed](#10-clear-completed)
58 | - [11. Live Components](#10-liveview-components)
59 | - [12. Deploy to Heroku](#11-deploy-to-heroku)
60 | - [`tl;dr`](#tldr)
61 |
62 |
63 |
64 | # Why? 🤷
65 |
66 | `Phoenix` is already an awesome web framework
67 | that helps teams build reliable Apps & APIs fast.
68 | `LiveView` takes the simplicity of building realtime features
69 | to the next level of elegance and simplicity.
70 |
71 | `LiveView` lets us create a slick single-page app
72 | with a **native** (_no lag or refresh_) experience
73 | without writing `JavaScript`.
74 |
75 | # What? 💭
76 |
77 | This tutorial builds a Todo List from scratch
78 | using Phoenix LiveView in _less_ than 20 minutes.
79 |
80 | # Who? 👤
81 |
82 | This tutorial is aimed at LiveView beginners
83 | who want to _understand_ how everything works
84 | using a familiar UI.
85 |
86 | If you are completely new to Phoenix and LiveView,
87 | we recommend you follow the **LiveView _Counter_ Tutorial**:
88 | https://github.com/dwyl/phoenix-liveview-counter-tutorial
89 |
90 | ## Prerequisites: What you Need _Before_ You Start 📝
91 |
92 | This tutorial expects you have a `Elixir`, `Phoenix` and `Node.js` installed.
93 | If you don't already have them on your computer,
94 | please see:
95 | https://github.com/dwyl/learn-elixir#installation
96 | and
97 | https://hexdocs.pm/phoenix/installation.html#phoenix
98 |
99 | # How? 💻
100 |
101 | > 💡 You can also try the version deployed to Heroku:
102 | > https://live-view-todo.herokuapp.com/
103 |
104 |
105 |
106 | ## Step 0: Run the _Finished_ Todo App on your `localhost` 🏃
107 |
108 | Before you attempt to _build_ the todo list app,
109 | we suggest that you clone and _run_
110 | the complete app on your `localhost`.
111 | That way you _know_ it's working
112 | without much effort/time expended.
113 |
114 | ### Clone the Repository
115 |
116 | On your `localhost`,
117 | run the following command to clone the repo
118 | and change into the directory:
119 |
120 | ```sh
121 | git clone https://github.com/dwyl/phoenix-liveview-todo-list-tutorial.git
122 | cd phoenix-liveview-todo-list-tutorial
123 | ```
124 |
125 | ### _Download_ the Dependencies
126 |
127 | Install the dependencies by running the command:
128 |
129 | ```sh
130 | mix setup
131 | ```
132 |
133 | It will take a few seconds to download the dependencies
134 | depending on the speed of your internet connection;
135 | be
136 | [patient](https://user-images.githubusercontent.com/194400/76169853-58139380-6174-11ea-8e03-4011815758d0.png).
137 | 😉
138 |
139 | ### _Run_ the App
140 |
141 | Start the Phoenix server by running the command:
142 |
143 | ```sh
144 | mix phx.server
145 | ```
146 |
147 | Now you can visit
148 | [`localhost:4000`](http://localhost:4000)
149 | in your web browser.
150 |
151 | > 💡 Open a _second_ browser window (_e.g. incognito mode_),
152 | > you will see the the counter updating in both places like magic!
153 |
154 | You should expect to see:
155 |
156 |
161 |
162 | With the _finished_ version of the App running on your machine
163 | and a clear picture of where we are headed, it's time to _build_ it!
164 |
165 |
166 |
167 | ## Step 1: Create the App 🆕
168 |
169 | In your terminal run the following `mix` command
170 | to generate the new Phoenix app:
171 |
172 | ```sh
173 | mix phx.new live_view_todo
174 | ```
175 |
176 | This command will setup the dependencies (including the liveView dependencies)
177 | and boilerplate for us to get going as fast as possible.
178 |
179 | When you see the following prompt in your terminal:
180 |
181 | ```sh
182 | Fetch and install dependencies? [Yn]
183 | ```
184 |
185 | Type Y followed by the Enter key.
186 | That will download all the necessary dependencies.
187 |
188 | ### Checkpoint 1a: _Run_ the _Tests_!
189 |
190 | In your terminal, go into the newly created app folder using:
191 |
192 | ```sh
193 | cd live_view_todo
194 | ```
195 |
196 | And then run the following `mix` command:
197 |
198 | ```sh
199 | mix test
200 | ```
201 |
202 | After the application is compiled you should see:
203 |
204 | ```
205 | ...
206 |
207 | Finished in 0.1 seconds (0.08s async, 0.05s sync)
208 | 3 tests, 0 failures
209 | ```
210 |
211 | Tests all pass.
212 | This is _expected_ with a new app.
213 | It's a good way to confirm everything is working.
214 |
215 |
216 |
217 | ### Checkpoint 1b: _Run_ the New Phoenix App!
218 |
219 | Run the server by executing this command:
220 |
221 | ```sh
222 | mix phx.server
223 | ```
224 |
225 | Visit
226 | [`localhost:4000`](http://localhost:4000)
227 | in your web browser.
228 |
229 | 
230 |
231 | > 🏁 Snapshot of code at the end of Step 1:
232 | > [`#25ba4e7`](https://github.com/dwyl/phoenix-liveview-todo-list-tutorial/commit/25ba4e75cee1dd038fff71aa1ba4b17330d692c9)
233 |
234 |
235 |
236 | ## 2. Create the TodoMVC UI/UX
237 |
238 | As we saw in the previous step, our App looks like a fresh Phoenix App.
239 | Let's make it look like a todo list.
240 |
241 | ### 2.1 Create live folder
242 |
243 | By convention Phoenix uses a `live` folder to manage the LiveView files.
244 | Create this folder at `lib/live_view_todo_web/live`.
245 |
246 | Next we can create the `PageLive` controller module. Create the
247 | `lib/live_view_todo_web/live/page_live.ex` and add the following content:
248 |
249 | ```elixir
250 | defmodule LiveViewTodoWeb.PageLive do
251 | use LiveViewTodoWeb, :live_view
252 |
253 | @impl true
254 | def mount(_params, _session, socket) do
255 | {:ok, socket}
256 | end
257 | end
258 | ```
259 |
260 | When using LiveView, the controller is required to implement
261 | the [`mount`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3) function,
262 | the entry point of the live page.
263 |
264 | ### 2.2 Update the Root Layout
265 |
266 | Open the `lib/live_view_todo_web/templates/layout/root.html.heex` file
267 | and remove the `` section
268 | such that the contents file is the following:
269 |
270 | ```html
271 |
272 |
273 |
274 |
275 |
276 |
277 | <%= csrf_meta_tag() %>
278 | <%= live_title_tag assigns[:page_title] || "LiveViewTodo", suffix: " · Phoenix Framework" %>
279 |
280 |
281 |
282 |
283 | <%= @inner_content %>
284 |
285 |
286 | ```
287 | ### 2.3 Create the page_live layout
288 |
289 | Create the `lib/live_view_todo_web/live/page_live.html.heex` layout file and
290 | add the following content:
291 |
292 | ```html
293 |
294 |
295 |
todos
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
335 |
336 | ```
337 |
338 |
339 | > **Note**: we borrowed this code from:
340 | > https://github.com/dwyl/phoenix-todo-list-tutorial#3-create-the-todomvc-uiux
341 | > our `Phoenix` (_without `LiveView`_) Todo List Tutorial.
342 |
343 | ### 2.4 Update Router and controller
344 |
345 | in `lib/live_view_todo_web/router.ex` file
346 | change `get` to `live` and rename the controller
347 | `PageController` to `PageLive`
348 |
349 | from:
350 |
351 | ```elixir
352 | scope "/", LiveViewTodoWeb do
353 | pipe_through :browser
354 |
355 | get "/", PageController, :index
356 | end
357 | ```
358 |
359 | to:
360 |
361 | ```elixir
362 | scope "/", LiveViewTodoWeb do
363 | pipe_through :browser
364 |
365 | live "/", PageLive
366 | end
367 | ```
368 |
369 | If you attempt to run the app now
370 | `mix phx.server` and visit
371 | [http://localhost:4000](http://localhost:4000)
372 | You will see this (_without the TodoMVC `CSS`_):
373 |
374 | 
375 |
376 | That's obviously not what we want,
377 | so let's get the TodoMVC `CSS`
378 | and save it in our project!
379 |
380 | ## 2.5 Save the TodoMVC CSS to `/assets/css`
381 |
382 | Visit
383 | https://todomvc.com/examples/vanillajs/node_modules/todomvc-app-css/index.css
384 | and save the file to `/assets/css/todomvc-app.css`
385 |
386 | e.g:
387 | [`/assets/css/todomvc-app.css`](https://github.com/dwyl/phoenix-todo-list-tutorial/blob/65bec23b92307527a414f77b667b29ea10619e5a/assets/css/todomvc-app.css)
388 |
389 |
390 |
391 | ## 2.6 Import the `todomvc-app.css` in `app.scss`
392 |
393 | Open the `assets/css/app.scss` file and replace it with the following:
394 |
395 | ```css
396 | /* This file is for your main application css. */
397 | /* @import "./phoenix.css"; */
398 | @import "./todomvc-app.css";
399 | ```
400 |
401 | We also commented out the line
402 | `@import "./phoenix.css";`
403 | because we don't want the Phoenix (Milligram) styles
404 | _conflicting_ with the TodoMVC ones.
405 |
406 | At the end of this step,
407 | if you run your Phoenix App with
408 | `mix phx.server`
409 | and visit:
410 | [http://localhost:4000](http://localhost:4000)
411 | you should see the following:
412 |
413 | 
414 |
415 | Now that we have the layout looking like we want it,
416 | we can move onto the fun part of making it _work_.
417 |
418 | ## 2.7 Update the test
419 |
420 | Now that we have a functioning LiveView page, let's create the tests under
421 | `test/live_view_todo_web/live` folder. Create the file
422 | `test/live_view_todo_web/live/page_live_test.exs` and add the following:
423 |
424 | ```elixir
425 | defmodule LiveViewTodoWeb.PageLiveTest do
426 | use LiveViewTodoWeb.ConnCase
427 | import Phoenix.LiveViewTest
428 |
429 | test "disconnected and connected mount", %{conn: conn} do
430 | {:ok, page_live, disconnected_html} = live(conn, "/")
431 | assert disconnected_html =~ "Todo"
432 | assert render(page_live) =~ "What needs to be done"
433 | end
434 | end
435 | ```
436 |
437 | and delete the `test/live_view_todo_web/controllers/page_controller_test.exs` file.
438 |
439 |
440 | Now when you re-run the tests:
441 |
442 | ```sh
443 | mix test
444 | ```
445 |
446 | You should see:
447 |
448 | ```sh
449 | Compiling 1 file (.ex)
450 | ...
451 |
452 | Finished in 0.2 seconds
453 | 3 tests, 0 failures
454 | ```
455 |
456 | Everything passing, lets get back to building!
457 |
458 |
459 |
460 | ## 3. Create the Todo List `items` Schema
461 |
462 | In order to _store_ the todo list `items` we need a schema.
463 | In your terminal run the following generator command:
464 |
465 | ```sh
466 | mix phx.gen.schema Item items text:string person_id:integer status:integer
467 | ```
468 |
469 | That will create two new files:
470 |
471 | - `lib/live_view_todo/item.ex` - the schema
472 | - `priv/repo/migrations/20201227070700_create_items.exs` - migration file (creates database table)
473 |
474 | Open the migration file to add a default value to `status`:
475 |
476 | ```elixir
477 | add :status, :integer, default: 0 # add default value 0
478 | ```
479 |
480 | Reference:
481 | https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Schema.html
482 |
483 | Execute the migration file by running the following command:
484 |
485 | ```sh
486 | mix ecto.migrate
487 | ```
488 |
489 | You will see output similar to the following:
490 |
491 | ```sh
492 | 13:44:03.406 [info] == Migrated 20170606070700 in 0.0s
493 | ```
494 |
495 | Now that the schema has been created
496 | we can write some code
497 | to make the todo list functionality work.
498 |
499 | ### 3.1 Add Aliases to `item.ex`
500 |
501 | Before we create any new functions, let's open the
502 | `lib/live_view_todo/item.ex`
503 | file and make a couple of changes:
504 |
505 | ```elixir
506 | defmodule LiveViewTodo.Item do
507 | use Ecto.Schema
508 | import Ecto.Changeset
509 |
510 | schema "items" do
511 | field :person_id, :integer
512 | field :status, :integer
513 | field :text, :string
514 |
515 | timestamps()
516 | end
517 |
518 | @doc false
519 | def changeset(item, attrs) do
520 | item
521 | |> cast(attrs, [:text, :person_id, :status])
522 | |> validate_required([:text, :person_id, :status])
523 | end
524 | end
525 | ```
526 |
527 | First add the line `alias LiveViewTodo.Repo`
528 | below the `import Ecto.Changeset` statement;
529 | we need this alias so that we can make database queries.
530 |
531 | Next add the line `alias __MODULE__` below the `alias` we just added;
532 | this just means "alias the Struct contained in this file so we can reference it".
533 | see: https://stackoverflow.com/questions/39854281/access-struct-inside-module/47501059
534 |
535 | Then add the default value for `status` to `0`:
536 |
537 | ```elixir
538 | field :status, :integer, default: 0
539 | ```
540 |
541 | Finally remove the `:person_id, :status`
542 | from the List of fields in `validate_required`.
543 | We don't want `person_id` to be required for now
544 | as we don't yet have authentication setup for the App.
545 |
546 | Your file should now look like this:
547 |
548 | ```elixir
549 | defmodule LiveViewTodo.Item do
550 | use Ecto.Schema
551 | import Ecto.Changeset
552 | alias LiveViewTodo.Repo
553 | alias __MODULE__
554 |
555 | schema "items" do
556 | field :person_id, :integer
557 | field :status, :integer
558 | field :text, :string
559 |
560 | timestamps()
561 | end
562 |
563 | @doc false
564 | def changeset(item, attrs) do
565 | item
566 | |> cast(attrs, [:text, :person_id, :status])
567 | |> validate_required([:text])
568 | end
569 | end
570 | ```
571 |
572 | With those changes made, we can proceed to creating our functions.
573 |
574 | ### 3.2 Create Todo Item CRUD Tests
575 |
576 | The `phx.gen.schema` does not automatically create any
577 | ["CRUD"](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)
578 | functions
579 | to `Create` an `item` or `Read` `items` in/from the database
580 | or tests for those functions,
581 | so we need to create them ourselves now.
582 |
583 | Create a new directory with the path:
584 | `test/live_view_todo`
585 | and in that new directory,
586 | create a file:
587 | `test/live_view_todo/item_test.exs`
588 |
589 | Next _open_ the newly created file
590 | `test/live_view_todo/item_test.exs`
591 | and add the following test code to it:
592 |
593 | ```elixir
594 | defmodule LiveViewTodo.ItemTest do
595 | use LiveViewTodo.DataCase
596 | alias LiveViewTodo.Item
597 |
598 | describe "items" do
599 | @valid_attrs %{text: "some text", person_id: 1, status: 0}
600 | @update_attrs %{text: "some updated text", status: 1}
601 | @invalid_attrs %{text: nil}
602 |
603 | def item_fixture(attrs \\ %{}) do
604 | {:ok, item} =
605 | attrs
606 | |> Enum.into(@valid_attrs)
607 | |> Item.create_item()
608 |
609 | item
610 | end
611 |
612 | test "get_item!/1 returns the item with given id" do
613 | item = item_fixture(@valid_attrs)
614 | assert Item.get_item!(item.id) == item
615 | end
616 |
617 | test "create_item/1 with valid data creates a item" do
618 | assert {:ok, %Item{} = item} = Item.create_item(@valid_attrs)
619 | assert item.text == "some text"
620 |
621 | inserted_item = List.first(Item.list_items())
622 | assert inserted_item.text == @valid_attrs.text
623 | end
624 |
625 | test "create_item/1 with invalid data returns error changeset" do
626 | assert {:error, %Ecto.Changeset{}} = Item.create_item(@invalid_attrs)
627 | end
628 |
629 | test "list_items/0 returns a list of todo items stored in the DB" do
630 | item1 = item_fixture()
631 | item2 = item_fixture()
632 | items = Item.list_items()
633 | assert Enum.member?(items, item1)
634 | assert Enum.member?(items, item2)
635 | end
636 |
637 | test "update_item/2 with valid data updates the item" do
638 | item = item_fixture()
639 | assert {:ok, %Item{} = item} = Item.update_item(item, @update_attrs)
640 | assert item.text == "some updated text"
641 | end
642 | end
643 | end
644 | ```
645 |
646 | Take a moment to _understand_ what is being tested.
647 | Once you have written out (_or let's face it, copy-pasted_) the test code,
648 | save the file and run the tests:
649 |
650 | ```
651 | mix test test/live_view_todo/item_test.exs
652 | ```
653 |
654 | Since the functions don't yet exist,
655 | you will see all the test _fail_:
656 |
657 | ```
658 | 1) test items get_item!/1 returns the item with given id (LiveViewTodo.ItemTest)
659 | test/live_view_todo/item_test.exs:19
660 | ** (UndefinedFunctionError) function LiveViewTodo.Item.create_item/1 is undefined or private
661 | code: item = item_fixture(@valid_attrs)
662 | stacktrace:
663 | (live_view_todo 0.1.0) LiveViewTodo.Item.create_item(%{person_id: 1, text: "some text"})
664 | test/live_view_todo/item_test.exs:14: LiveViewTodo.ItemTest.item_fixture/1
665 | test/live_view_todo/item_test.exs:20: (test)
666 |
667 | etc ...
668 |
669 | Finished in 0.2 seconds
670 | 5 tests, 5 failures
671 | ```
672 |
673 | Hopefully these CRUD tests are familiar to you.
674 | If they aren't, please read:
675 | https://hexdocs.pm/phoenix/testing.html
676 | If you still have any doubts, please
677 | [ask a specific question](https://github.com/dwyl/phoenix-liveview-todo-list-tutorial/issues/new).
678 |
679 | The focus of this tutorial is `LiveView` not CRUD testing,
680 | the sooner we get to the `LievView` part the better,
681 | this is just the "setup" we need to do for inserting todo item data.
682 |
683 | Let's write the functions to make the tests pass!
684 |
685 | ### 3.3 Make the CRUD Tests _Pass_
686 |
687 | Open the `lib/live_view_todo/item.ex` file
688 | and add the following lines of code:
689 |
690 | ```elixir
691 | @doc """
692 | Creates a item.
693 |
694 | ## Examples
695 |
696 | iex> create_item(%{text: "Learn LiveView"})
697 | {:ok, %Item{}}
698 |
699 | iex> create_item(%{text: nil})
700 | {:error, %Ecto.Changeset{}}
701 |
702 | """
703 | def create_item(attrs \\ %{}) do
704 | %Item{}
705 | |> changeset(attrs)
706 | |> Repo.insert()
707 | end
708 |
709 | @doc """
710 | Gets a single item.
711 |
712 | Raises `Ecto.NoResultsError` if the Item does not exist.
713 |
714 | ## Examples
715 |
716 | iex> get_item!(123)
717 | %Item{}
718 |
719 | iex> get_item!(456)
720 | ** (Ecto.NoResultsError)
721 |
722 | """
723 | def get_item!(id), do: Repo.get!(Item, id)
724 |
725 |
726 | @doc """
727 | Returns the list of items.
728 |
729 | ## Examples
730 |
731 | iex> list_items()
732 | [%Item{}, ...]
733 |
734 | """
735 | def list_items do
736 | Repo.all(Item)
737 | end
738 |
739 | @doc """
740 | Updates a item.
741 |
742 | ## Examples
743 |
744 | iex> update_item(item, %{field: new_value})
745 | {:ok, %Item{}}
746 |
747 | iex> update_item(item, %{field: bad_value})
748 | {:error, %Ecto.Changeset{}}
749 |
750 | """
751 | def update_item(%Item{} = item, attrs) do
752 | item
753 | |> Item.changeset(attrs)
754 | |> Repo.update()
755 | end
756 | ```
757 |
758 | After saving the `item.ex` file,
759 | re-run the tests with:
760 |
761 | ```sh
762 | mix test test/live_view_todo/item_test.exs
763 | ```
764 |
765 | You should see them pass:
766 |
767 | ```sh
768 | .....
769 |
770 | Finished in 0.2 seconds
771 | 5 tests, 0 failures
772 |
773 | Randomized with seed 208543
774 | ```
775 |
776 | Now that we have our CRUD functions written (_and documented+tested_),
777 | we can move on to the _fun_ part, building the Todo App in `LiveView`!
778 |
779 |
780 |
781 | ## 4. Handle Todo List `Item` Creation
782 |
783 | The first event we want to handle in our `LiveView` App is "create";
784 | the act of creating a new Todo List `item`.
785 |
786 | Let's start by adding a _test_ for creating an item.
787 | Open the
788 | `test/live_view_todo_web/live/page_live_test.exs`
789 | file and add the following test:
790 |
791 | ```elixir
792 | test "connect and create a todo item", %{conn: conn} do
793 | {:ok, view, _html} = live(conn, "/")
794 | assert render_submit(view, :create, %{"text" => "Learn Elixir"}) =~ "Learn Elixir"
795 | end
796 | ```
797 |
798 | Docs for this LiveView testing using `render_submit/1`:
799 | https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html#render_submit/1
800 |
801 |
802 |
803 | If you attempt to run this test:
804 |
805 | ```sh
806 | mix test test/live_view_todo_web/live/page_live_test.exs
807 | ```
808 |
809 | you will see it _fail_:
810 |
811 | ```sh
812 | 1) test connect and create a todo item (LiveViewTodoWeb.PageLiveTest)
813 | test/live_view_todo_web/live/page_live_test.exs:12
814 | ** (EXIT from #PID<0.441.0>) an exception was raised:
815 |
816 | ** (FunctionClauseError) no function clause matching in LiveViewTodoWeb.PageLive.handle_event/3
817 | ```
818 |
819 | In order to make the test _pass_ we will need to add two blocks of code.
820 |
821 | Open the `lib/live_view_todo_web/live/page_live.html.heex` file
822 | and locate the line in the `` section:
823 |
824 | ```html
825 |
826 | ```
827 |
828 | Replace it with the following:
829 |
830 | ```html
831 |
842 | ```
843 |
844 | The important part is the `phx-submit="create"`
845 | which tells `LiveView` which event to emit when the form is submitted.
846 |
847 | Once you've saved the `page_live.html.leex` file,
848 | open the `lib/live_view_todo_web/live/page_live.ex` file
849 | and under `use LiveViewTodoWeb, :live_view` add
850 |
851 | ```elixir
852 | alias LiveViewTodo.Item
853 |
854 | @topic "live"
855 |
856 | ```
857 |
858 |
859 | and the add the following handler code after the `mount` function:
860 |
861 | ```elixir
862 |
863 | @impl true
864 | def handle_event("create", %{"text" => text}, socket) do
865 | Item.create_item(%{text: text})
866 | socket = assign(socket, items: Item.list_items(), active: %Item{})
867 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
868 | {:noreply, socket}
869 | end
870 | ```
871 |
872 | The `@topic "live"` is the WebSocket (_Phoenix Channel_) topic
873 | defined as a
874 | [module attribute](https://elixir-lang.org/getting-started/module-attributes.html)
875 | (_like a Global Constant_),
876 | which we will use to both subscribe to and broadcast on.
877 |
878 | So the following line:
879 |
880 | ```elixir
881 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
882 | ```
883 |
884 | Will send the "update" event with the `socket.assigns` data
885 | to all the other clients on listening to the @topic.
886 | Now to listen to this message we can define the [handle_info](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_info/2) callback.
887 | Add the following code:
888 |
889 | ```elixir
890 | @impl true
891 | def handle_info(%{event: "update", payload: %{items: items}}, socket) do
892 | {:noreply, assign(socket, items: items)}
893 | end
894 | ```
895 |
896 | We are using pattern matching on the first parameter to make sure
897 | the handle_info matches the "update" event. We then assign to the socket
898 | the new list of items.
899 |
900 |
901 | With that in place you can now create items in the browser!
902 | Run the app: `mix phx.sever` and you should be able to add items.
903 | _However_ they will not _appear_ in the UI.
904 | Let's fix that next.
905 |
906 |
907 |
908 | ## 5. _Show_ the Created Todo `Items`
909 |
910 | In order to _show_ the Todo `items` we are creating,
911 | we need to:
912 |
913 | 1. Lookup and assign the `items` in the `mount/3` function
914 | 2. Loop through and render the `item` in the `page_live.html.leex` template
915 |
916 | Let's start by updating the `mount/3` function in
917 | `/lib/live_view_todo_web/live/page_live.ex`:
918 |
919 | ```elixir
920 | def mount(_params, _session, socket) do
921 | # subscribe to the channel
922 | if connected?(socket), do: LiveViewTodoWeb.Endpoint.subscribe(@topic)
923 | {:ok, assign(socket, items: Item.list_items())} # add items to assigns
924 | end
925 | ```
926 |
927 | Then in the
928 | `lib/live_view_todo_web/live/page_live.html.leex` file
929 | replace the code:
930 |
931 | ```html
932 |
933 |
934 |
935 |
936 |
937 |
938 |
939 |
940 |
941 |
942 |
943 |
944 |
945 |
946 |
947 |
948 | ```
949 |
950 | With the following:
951 |
952 | ```elixir
953 |
954 | <%= for item <- @items do %>
955 |
956 |
957 | <%= if checked?(item) do %>
958 |
959 | <% else %>
960 |
961 | <% end %>
962 |
963 |
964 |
965 |
966 | <% end %>
967 |
968 | ```
969 |
970 | You will notice that there are two functions
971 | `completed?/1` and `checked?/1`
972 | invoked in that block of template code.
973 |
974 | We need to define the functions in
975 | `/lib/live_view_todo_web/live/page_live.ex`:
976 |
977 | ```elixir
978 | def checked?(item) do
979 | not is_nil(item.status) and item.status > 0
980 | end
981 |
982 | def completed?(item) do
983 | if not is_nil(item.status) and item.status > 0, do: "completed", else: ""
984 | end
985 | ```
986 |
987 | These are convenience functions.
988 | We _could_ have embedded this code directly in the template,
989 | however we prefer to _minimize_ logic in the templates
990 | so that they are easier to read/maintain.
991 |
992 | With that template update and helper functions saved,
993 | we can now create and _see_ our created Todo `item`:
994 |
995 | 
996 |
997 |
998 |
999 | ## 6. Toggle the State of Todo Items
1000 |
1001 | The next piece of functionality we want in a Todo List
1002 | is the ability to **`toggle`** the completion from "todo" to "done".
1003 |
1004 | In our `item` `schema` (created in step 3),
1005 | we defined `status` as an `integer`.
1006 | The `default` value for `item.status`
1007 | when a **new `item`** is inserted is `0`.
1008 |
1009 |
1010 |
1011 | Let's create a (_failing_) test for **toggling** items.
1012 | Open the
1013 | `test/live_view_todo_web/live/page_live_test.exs`
1014 | file and add the following test to it:
1015 |
1016 | ```elixir
1017 | test "toggle an item", %{conn: conn} do
1018 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})
1019 | assert item.status == 0
1020 |
1021 | {:ok, view, _html} = live(conn, "/")
1022 | assert render_click(view, :toggle, %{"id" => item.id, "value" => 1}) =~ "completed"
1023 |
1024 | updated_item = Item.get_item!(item.id)
1025 | assert updated_item.status == 1
1026 | end
1027 | ```
1028 |
1029 | Make sure to alias the `Item` structure in your test file:
1030 |
1031 | ```elixir
1032 | defmodule LiveViewTodoWeb.PageLiveTest do
1033 | use LiveViewTodoWeb.ConnCase
1034 | import Phoenix.LiveViewTest
1035 | alias LiveViewTodo.Item # alias Item here
1036 | ```
1037 |
1038 | You may have noticed that in the template,
1039 | we included an `` with the `type="checkbox"`
1040 |
1041 | ```elixir
1042 | <%= if checked?(item) do %>
1043 |
1044 | <% else %>
1045 |
1046 | <% end %>
1047 | ```
1048 |
1049 | These lines of code already has everything we need to enable the **`toggle`** feature
1050 | on the front-end, we just need to create a handler in `page_live.ex`
1051 | to handle the event.
1052 |
1053 | Open the
1054 | `/lib/live_view_todo_web/live/page_live.ex`
1055 | file and add the following code to it:
1056 |
1057 | ```elixir
1058 | @impl true
1059 | def handle_event("toggle", data, socket) do
1060 | status = if Map.has_key?(data, "value"), do: 1, else: 0
1061 | item = Item.get_item!(Map.get(data, "id"))
1062 | Item.update_item(item, %{id: item.id, status: status})
1063 | socket = assign(socket, items: Item.list_items(), active: %Item{})
1064 | LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns)
1065 | {:noreply, socket}
1066 | end
1067 | ```
1068 |
1069 | Note that we are using `broadcast/3` instead of `broadcast_from/4` to make
1070 | sure the count of items left is updated for the client itself.
1071 |
1072 | Once you've saved the file,
1073 | the test will pass.
1074 |
1075 |
1076 |
1077 | ## 7. "Delete" a Todo `item`
1078 |
1079 | Rather than _permanently_ deleting items which destroys history/accountability,
1080 | we prefer to
1081 | ["_soft deletion_"](https://en.wiktionary.org/wiki/soft_deletion)
1082 | which allows people to "undo" the operation.
1083 |
1084 | Open
1085 | `test/live_view_todo/item_test.exs`
1086 | and add the following test to it:
1087 |
1088 | ```elixir
1089 | test "delete_item/1 soft-deletes an item" do
1090 | item = item_fixture()
1091 | assert {:ok, %Item{} = deleted_item} = Item.delete_item(item.id)
1092 | assert deleted_item.status == 2
1093 | end
1094 | ```
1095 |
1096 | If you attempt to run the test,
1097 | you will see it _fail_:
1098 |
1099 | ```sh
1100 | 1) test items delete_item/1 soft-deltes an item (LiveViewTodo.ItemTest)
1101 | test/live_view_todo/item_test.exs:50
1102 | ** (UndefinedFunctionError) function LiveViewTodo.Item.delete_item/1 is undefined or private
1103 | code: assert {:ok, %Item{} = deleted_item} = Item.delete_item(item.id)
1104 | stacktrace:
1105 | (live_view_todo 0.1.0) LiveViewTodo.Item.delete_item(157)
1106 | test/live_view_todo/item_test.exs:52: (test)
1107 | ```
1108 |
1109 | To make the test _pass_,
1110 | open your `lib/live_view_todo/item.ex` file
1111 | and add the following function definition:
1112 |
1113 | ```elixir
1114 | def delete_item(id) do
1115 | get_item!(id)
1116 | |> Item.changeset(%{status: 2})
1117 | |> Repo.update()
1118 | end
1119 | ```
1120 |
1121 | Having defined the `delete/1` function
1122 | as updating the `item.status` to **`2`**,
1123 | we can now create a test for a `LiveView` handler
1124 | that invokes this function.
1125 |
1126 | Open the
1127 | `test/live_view_todo_web/live/page_live_test.exs`
1128 | file and add the following test to it:
1129 |
1130 | ```elixir
1131 | test "delete an item", %{conn: conn} do
1132 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})
1133 | assert item.status == 0
1134 |
1135 | {:ok, view, _html} = live(conn, "/")
1136 | assert render_click(view, :delete, %{"id" => item.id})
1137 |
1138 | updated_item = Item.get_item!(item.id)
1139 | assert updated_item.status == 2
1140 | end
1141 | ```
1142 |
1143 | To make this test pass,
1144 | we need to add the following `handle_event/3` handler to `page_live.ex`:
1145 |
1146 | ```elixir
1147 | @impl true
1148 | def handle_event("delete", data, socket) do
1149 | Item.delete_item(Map.get(data, "id"))
1150 | socket = assign(socket, items: Item.list_items(), active: %Item{})
1151 | LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns)
1152 | {:noreply, socket}
1153 | end
1154 | ```
1155 |
1156 | This point we've written a bunch of code,
1157 | let's see it in _action_ in the front-end.
1158 |
1159 | Run the Phoenix Sever: `mix phx.server`
1160 | and visit
1161 | [http://localhost:4000](http://localhost:4000)
1162 | in your web browser.
1163 | You should see:
1164 |
1165 | 
1166 |
1167 |
1168 |
1169 | ## 8. Editing Todo `item.text`
1170 |
1171 | For _editing_ an `item` we'll continue to use `LiveView` and:
1172 |
1173 | - 1. Display the "**edit**" form when an `item` is clicked on
1174 | - 2. On submit, `LiveView` will handle the **`update-item`** event to **update** the `item`
1175 |
1176 | First we want to **update** the `html` to display the `form` when an `item` is edited:
1177 |
1178 | update `lib/live_view_todo_web/live/page_live.html.heex` to display the form:
1179 |
1180 | ```html
1181 |
1182 | <%= for item <- @items do %>
1183 | <%= if item.id == @editing do %>
1184 |
1195 | <% else %>
1196 |
1197 |
1198 | <%= if checked?(item) do %>
1199 |
1200 | <% else %>
1201 |
1202 | <% end %>
1203 |
1204 |
1205 |
1206 |
1207 | <% end %>
1208 | <% end %>
1209 |
1210 | ```
1211 |
1212 | For each `item` we check
1213 | if the `item.id`
1214 | matches the `@editing` value
1215 | and we display
1216 | either the `form` or the `label` value.
1217 |
1218 | We have added the `phx-click="edit-item"` event on the `label` which is used
1219 | to define the `@editing` value:
1220 |
1221 | in `lib/live_view_todo_web/live/page_live.ex` create the logic for `edit-item` event:
1222 |
1223 | ```elixir
1224 | @impl true
1225 | def handle_event("edit-item", data, socket) do
1226 | {:noreply, assign(socket, editing: String.to_integer(data["id"]))}
1227 | end
1228 | ```
1229 |
1230 | We assign the `editing` value
1231 | to the socket with the `item.id`
1232 | defined by
1233 | `phx-value-id`.
1234 |
1235 | Finally we can handle the `phx-submit="update-item"` event:
1236 |
1237 | ```elixir
1238 | @impl true
1239 | def handle_event("update-item", %{"id" => item_id, "text" => text}, socket) do
1240 | current_item = Item.get_item!(item_id)
1241 | Item.update_item(current_item, %{text: text})
1242 | items = Item.list_items()
1243 | socket = assign(socket, items: items, editing: nil)
1244 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
1245 | {:noreply, socket}
1246 | end
1247 | ```
1248 |
1249 | We update the item matching the id with the new text value and broadcast the change
1250 | to the other connected clients.
1251 |
1252 | Let's update the tests to make sure the editing feature is covered:
1253 |
1254 | ```elixir
1255 | test "edit item", %{conn: conn} do
1256 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})
1257 |
1258 | {:ok, view, _html} = live(conn, "/")
1259 |
1260 | assert render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) =~
1261 | "
1525 | <% else %>
1526 |
1527 |
1528 |
1537 |
1545 |
1553 |
1554 |
1555 | <% end %>
1556 | <% end %>
1557 |
1558 | """
1559 | end
1560 | end
1561 | ```
1562 |
1563 | We have defined the `render` function which display the list of items.
1564 | Note that we have also defined the `attr` function. This tells us that we need
1565 | to pass the `:items` attribute when calling our component.
1566 |
1567 | In `lib/live_view_todo_web/live/page_live.html.heex` we can already call our component:
1568 |
1569 | ```heex
1570 |
1571 |
1572 |
1573 | <.live_component
1574 | module={LiveViewTodoWeb.ItemComponent}
1575 | id="cpn"
1576 | items={@items}
1577 | editing={@editing}
1578 | />
1579 |
1580 | ```
1581 |
1582 | Now that we have moved the `ul` and `li` tags to the render function we can
1583 | directly use `<.live_component/>`. Make sure to define the `module` and `id`.
1584 | We can also see that we have the `items` and `editing` attribute too.
1585 |
1586 | Finally we can move the `handle_event` linked to the items in `live_page.ex`
1587 | to the `item_component.ex` file:
1588 |
1589 | ```elixir
1590 | def render(assigns) do
1591 | ...
1592 | end
1593 |
1594 | @impl true
1595 | def handle_event("toggle", data, socket) do
1596 | status = if Map.has_key?(data, "value"), do: 1, else: 0
1597 | item = Item.get_item!(Map.get(data, "id"))
1598 |
1599 | Item.update_item(item, %{id: item.id, status: status})
1600 |
1601 | socket = assign(socket, items: Item.list_items(), active: %Item{})
1602 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
1603 | {:noreply, socket}
1604 | end
1605 |
1606 | @impl true
1607 | def handle_event("edit-item", data, socket) do
1608 | {:noreply, assign(socket, editing: String.to_integer(data["id"]))}
1609 | end
1610 |
1611 | @impl true
1612 | def handle_event("update-item", %{"id" => item_id, "text" => text}, socket) do
1613 | current_item = Item.get_item!(item_id)
1614 | Item.update_item(current_item, %{text: text})
1615 | items = Item.list_items()
1616 | socket = assign(socket, items: items, editing: nil)
1617 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
1618 | {:noreply, socket}
1619 | end
1620 |
1621 | @impl true
1622 | def handle_event("delete", data, socket) do
1623 | Item.delete_item(Map.get(data, "id"))
1624 | socket = assign(socket, items: Item.list_items(), active: %Item{})
1625 | LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns)
1626 | {:noreply, socket}
1627 | end
1628 |
1629 | def checked?(item) do
1630 | not is_nil(item.status) and item.status > 0
1631 | end
1632 |
1633 | def completed?(item) do
1634 | if not is_nil(item.status) and item.status > 0, do: "completed", else: ""
1635 | end
1636 | ```
1637 |
1638 | More documentation:
1639 |
1640 | - https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html
1641 | - https://elixirschool.com/blog/live-view-live-component
1642 |
1643 | ## 12. Deploy to Heroku
1644 |
1645 | Deployment is beyond the scope of this tutorial.
1646 | But we created a _separate_
1647 | guide for it:
1648 | [elixir-phoenix-app-deployment.md](https://github.com/dwyl/learn-heroku/blob/master/elixir-phoenix-app-deployment.md)
1649 |
1650 | Once you have _deployed_ you will will be able
1651 | to view/use your app in any Web/Mobile Browser.
1652 |
1653 | e.g:
1654 | https://liveview-todo.herokuapp.com
1655 |
1656 | ### `tl;dr`
1657 |
1658 | - [x] Add the build packs
1659 |
1660 | Run the commands:
1661 |
1662 | ```
1663 | heroku git:remote -a liveview-todo
1664 | heroku run "POOL_SIZE=2 mix ecto.migrate"
1665 | ```
1666 |
1667 |
1668 |
1669 |
1674 |
--------------------------------------------------------------------------------