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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/assets/css/phoenix.css:
--------------------------------------------------------------------------------
1 | /* Includes some default style for the starter application.
2 | * This can be safely deleted to start fresh.
3 | */
4 |
5 | /* Milligram v1.3.0 https://milligram.github.io
6 | * Copyright (c) 2017 CJ Patoilo Licensed under the MIT license
7 | */
8 |
9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}
10 |
11 | /* General style */
12 | h1{font-size: 3.6rem; line-height: 1.25}
13 | h2{font-size: 2.8rem; line-height: 1.3}
14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35}
15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5}
16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4}
17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2}
18 | pre{padding: 1em;}
19 |
20 | .container{
21 | margin: 0 auto;
22 | max-width: 80.0rem;
23 | padding: 0 2.0rem;
24 | position: relative;
25 | width: 100%
26 | }
27 | select {
28 | width: auto;
29 | }
30 |
31 | /* Phoenix promo and logo */
32 | .phx-hero {
33 | text-align: center;
34 | border-bottom: 1px solid #e3e3e3;
35 | background: #eee;
36 | border-radius: 6px;
37 | padding: 3em 3em 1em;
38 | margin-bottom: 3rem;
39 | font-weight: 200;
40 | font-size: 120%;
41 | }
42 | .phx-hero input {
43 | background: #ffffff;
44 | }
45 | .phx-logo {
46 | min-width: 300px;
47 | margin: 1rem;
48 | display: block;
49 | }
50 | .phx-logo img {
51 | width: auto;
52 | display: block;
53 | }
54 |
55 | /* Headers */
56 | header {
57 | width: 100%;
58 | background: #fdfdfd;
59 | border-bottom: 1px solid #eaeaea;
60 | margin-bottom: 2rem;
61 | }
62 | header section {
63 | align-items: center;
64 | display: flex;
65 | flex-direction: column;
66 | justify-content: space-between;
67 | }
68 | header section :first-child {
69 | order: 2;
70 | }
71 | header section :last-child {
72 | order: 1;
73 | }
74 | header nav ul,
75 | header nav li {
76 | margin: 0;
77 | padding: 0;
78 | display: block;
79 | text-align: right;
80 | white-space: nowrap;
81 | }
82 | header nav ul {
83 | margin: 1rem;
84 | margin-top: 0;
85 | }
86 | header nav a {
87 | display: block;
88 | }
89 |
90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */
91 | header section {
92 | flex-direction: row;
93 | }
94 | header nav ul {
95 | margin: 1rem;
96 | }
97 | .phx-logo {
98 | flex-basis: 527px;
99 | margin: 2rem 1rem;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/assets/css/todomvc-app.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | button {
7 | margin: 0;
8 | padding: 0;
9 | border: 0;
10 | background: none;
11 | font-size: 100%;
12 | vertical-align: baseline;
13 | font-family: inherit;
14 | font-weight: inherit;
15 | color: inherit;
16 | -webkit-appearance: none;
17 | appearance: none;
18 | -webkit-font-smoothing: antialiased;
19 | -moz-osx-font-smoothing: grayscale;
20 | }
21 |
22 | body {
23 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
24 | line-height: 1.4em;
25 | background: #f5f5f5;
26 | color: #4d4d4d;
27 | min-width: 230px;
28 | max-width: 550px;
29 | margin: 0 auto;
30 | -webkit-font-smoothing: antialiased;
31 | -moz-osx-font-smoothing: grayscale;
32 | font-weight: 300;
33 | }
34 |
35 | :focus {
36 | outline: 0;
37 | }
38 |
39 | .hidden {
40 | display: none;
41 | }
42 |
43 | .todoapp {
44 | background: #fff;
45 | margin: 130px 0 40px 0;
46 | position: relative;
47 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
48 | 0 25px 50px 0 rgba(0, 0, 0, 0.1);
49 | }
50 |
51 | .todoapp input::-webkit-input-placeholder {
52 | font-style: italic;
53 | font-weight: 300;
54 | color: #e6e6e6;
55 | }
56 |
57 | .todoapp input::-moz-placeholder {
58 | font-style: italic;
59 | font-weight: 300;
60 | color: #e6e6e6;
61 | }
62 |
63 | .todoapp input::input-placeholder {
64 | font-style: italic;
65 | font-weight: 300;
66 | color: #e6e6e6;
67 | }
68 |
69 | .todoapp h1 {
70 | position: absolute;
71 | top: -155px;
72 | width: 100%;
73 | font-size: 100px;
74 | font-weight: 100;
75 | text-align: center;
76 | color: rgba(175, 47, 47, 0.15);
77 | -webkit-text-rendering: optimizeLegibility;
78 | -moz-text-rendering: optimizeLegibility;
79 | text-rendering: optimizeLegibility;
80 | }
81 |
82 | .new-todo,
83 | .edit {
84 | position: relative;
85 | margin: 0;
86 | width: 100%;
87 | font-size: 24px;
88 | font-family: inherit;
89 | font-weight: inherit;
90 | line-height: 1.4em;
91 | border: 0;
92 | color: inherit;
93 | padding: 6px;
94 | border: 1px solid #999;
95 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
96 | box-sizing: border-box;
97 | -webkit-font-smoothing: antialiased;
98 | -moz-osx-font-smoothing: grayscale;
99 | }
100 |
101 | .new-todo {
102 | padding: 16px 16px 16px 60px;
103 | border: none;
104 | background: rgba(0, 0, 0, 0.003);
105 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
106 | }
107 |
108 | .main {
109 | position: relative;
110 | z-index: 2;
111 | border-top: 1px solid #e6e6e6;
112 | }
113 |
114 | .toggle-all {
115 | width: 1px;
116 | height: 1px;
117 | border: none; /* Mobile Safari */
118 | opacity: 0;
119 | position: absolute;
120 | right: 100%;
121 | bottom: 100%;
122 | }
123 |
124 | .toggle-all + label {
125 | width: 60px;
126 | height: 34px;
127 | font-size: 0;
128 | position: absolute;
129 | top: -52px;
130 | left: -13px;
131 | -webkit-transform: rotate(90deg);
132 | transform: rotate(90deg);
133 | }
134 |
135 | .toggle-all + label:before {
136 | content: '❯';
137 | font-size: 22px;
138 | color: #e6e6e6;
139 | padding: 10px 27px 10px 27px;
140 | }
141 |
142 | .toggle-all:checked + label:before {
143 | color: #737373;
144 | }
145 |
146 | .todo-list {
147 | margin: 0;
148 | padding: 0;
149 | list-style: none;
150 | }
151 |
152 | .todo-list li {
153 | position: relative;
154 | font-size: 24px;
155 | border-bottom: 1px solid #ededed;
156 | }
157 |
158 | .todo-list li:last-child {
159 | border-bottom: none;
160 | }
161 |
162 | .todo-list li.editing {
163 | border-bottom: none;
164 | padding: 0;
165 | }
166 |
167 | .todo-list li.editing .edit {
168 | display: block;
169 | width: 506px;
170 | padding: 12px 16px;
171 | margin: 0 0 0 43px;
172 | }
173 |
174 | .todo-list li.editing .view {
175 | display: none;
176 | }
177 |
178 | .todo-list li .toggle {
179 | text-align: center;
180 | width: 40px;
181 | /* auto, since non-WebKit browsers doesn't support input styling */
182 | height: auto;
183 | position: absolute;
184 | top: 0;
185 | bottom: 0;
186 | margin: auto 0;
187 | border: none; /* Mobile Safari */
188 | -webkit-appearance: none;
189 | appearance: none;
190 | }
191 |
192 | .todo-list li .toggle {
193 | opacity: 0;
194 | }
195 |
196 | .todo-list li .toggle + label {
197 | /*
198 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
199 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
200 | */
201 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
202 | background-repeat: no-repeat;
203 | background-position: center left;
204 | }
205 |
206 | .todo-list li .toggle:checked + label {
207 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
208 | }
209 |
210 | .todo-list li label {
211 | word-break: break-all;
212 | padding: 15px 15px 15px 60px;
213 | display: block;
214 | line-height: 1.2;
215 | transition: color 0.4s;
216 | }
217 |
218 | .todo-list li.completed label {
219 | color: #d9d9d9;
220 | text-decoration: line-through;
221 | }
222 |
223 | .todo-list li .destroy {
224 | display: none;
225 | position: absolute;
226 | top: 0;
227 | right: 10px;
228 | bottom: 0;
229 | width: 40px;
230 | height: 40px;
231 | margin: auto 0;
232 | font-size: 30px;
233 | color: #cc9a9a;
234 | margin-bottom: 11px;
235 | transition: color 0.2s ease-out;
236 | }
237 |
238 | .todo-list li .destroy:hover {
239 | color: #af5b5e;
240 | }
241 |
242 | .todo-list li .destroy:after {
243 | content: '×';
244 | }
245 |
246 | .todo-list li:hover .destroy {
247 | display: block;
248 | }
249 |
250 | .todo-list li .edit {
251 | display: none;
252 | }
253 |
254 | .todo-list li.editing:last-child {
255 | margin-bottom: -1px;
256 | }
257 |
258 | .footer {
259 | color: #777;
260 | padding: 10px 15px;
261 | height: 20px;
262 | text-align: center;
263 | border-top: 1px solid #e6e6e6;
264 | }
265 |
266 | .footer:before {
267 | content: '';
268 | position: absolute;
269 | right: 0;
270 | bottom: 0;
271 | left: 0;
272 | height: 50px;
273 | overflow: hidden;
274 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
275 | 0 8px 0 -3px #f6f6f6,
276 | 0 9px 1px -3px rgba(0, 0, 0, 0.2),
277 | 0 16px 0 -6px #f6f6f6,
278 | 0 17px 2px -6px rgba(0, 0, 0, 0.2);
279 | }
280 |
281 | .todo-count {
282 | float: left;
283 | text-align: left;
284 | }
285 |
286 | .todo-count strong {
287 | font-weight: 300;
288 | }
289 |
290 | .filters {
291 | margin: 0;
292 | padding: 0;
293 | list-style: none;
294 | position: absolute;
295 | right: 0;
296 | left: 0;
297 | }
298 |
299 | .filters li {
300 | display: inline;
301 | }
302 |
303 | .filters li a {
304 | color: inherit;
305 | margin: 3px;
306 | padding: 3px 7px;
307 | text-decoration: none;
308 | border: 1px solid transparent;
309 | border-radius: 3px;
310 | }
311 |
312 | .filters li a:hover {
313 | border-color: rgba(175, 47, 47, 0.1);
314 | }
315 |
316 | .filters li a.selected {
317 | border-color: rgba(175, 47, 47, 0.2);
318 | }
319 |
320 | .clear-completed,
321 | html .clear-completed:active {
322 | float: right;
323 | position: relative;
324 | line-height: 20px;
325 | text-decoration: none;
326 | cursor: pointer;
327 | }
328 |
329 | .clear-completed:hover {
330 | text-decoration: underline;
331 | }
332 |
333 | .info {
334 | margin: 65px auto 0;
335 | color: #bfbfbf;
336 | font-size: 10px;
337 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
338 | text-align: center;
339 | }
340 |
341 | .info p {
342 | line-height: 1;
343 | }
344 |
345 | .info a {
346 | color: inherit;
347 | text-decoration: none;
348 | font-weight: 400;
349 | }
350 |
351 | .info a:hover {
352 | text-decoration: underline;
353 | }
354 |
355 | /*
356 | Hack to remove background from Mobile Safari.
357 | Can't use it globally since it destroys checkboxes in Firefox
358 | */
359 | @media screen and (-webkit-min-device-pixel-ratio:0) {
360 | .toggle-all,
361 | .todo-list li .toggle {
362 | background: none;
363 | }
364 |
365 | .todo-list li .toggle {
366 | height: 40px;
367 | }
368 | }
369 |
370 | @media (max-width: 430px) {
371 | .footer {
372 | height: 50px;
373 | }
374 |
375 | .filters {
376 | bottom: 10px;
377 | }
378 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 1.0.0, 2021-01-06
4 | * http://buunguyen.github.io/topbar
5 | * Copyright (c) 2021 Buu Nguyen
6 | */
7 | (function (window, document) {
8 | "use strict";
9 |
10 | // https://gist.github.com/paulirish/1579671
11 | (function () {
12 | var lastTime = 0;
13 | var vendors = ["ms", "moz", "webkit", "o"];
14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
15 | window.requestAnimationFrame =
16 | window[vendors[x] + "RequestAnimationFrame"];
17 | window.cancelAnimationFrame =
18 | window[vendors[x] + "CancelAnimationFrame"] ||
19 | window[vendors[x] + "CancelRequestAnimationFrame"];
20 | }
21 | if (!window.requestAnimationFrame)
22 | window.requestAnimationFrame = function (callback, element) {
23 | var currTime = new Date().getTime();
24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime));
25 | var id = window.setTimeout(function () {
26 | callback(currTime + timeToCall);
27 | }, timeToCall);
28 | lastTime = currTime + timeToCall;
29 | return id;
30 | };
31 | if (!window.cancelAnimationFrame)
32 | window.cancelAnimationFrame = function (id) {
33 | clearTimeout(id);
34 | };
35 | })();
36 |
37 | var canvas,
38 | progressTimerId,
39 | fadeTimerId,
40 | currentProgress,
41 | showing,
42 | addEvent = function (elem, type, handler) {
43 | if (elem.addEventListener) elem.addEventListener(type, handler, false);
44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
45 | else elem["on" + type] = handler;
46 | },
47 | options = {
48 | autoRun: true,
49 | barThickness: 3,
50 | barColors: {
51 | 0: "rgba(26, 188, 156, .9)",
52 | ".25": "rgba(52, 152, 219, .9)",
53 | ".50": "rgba(241, 196, 15, .9)",
54 | ".75": "rgba(230, 126, 34, .9)",
55 | "1.0": "rgba(211, 84, 0, .9)",
56 | },
57 | shadowBlur: 10,
58 | shadowColor: "rgba(0, 0, 0, .6)",
59 | className: null,
60 | },
61 | repaint = function () {
62 | canvas.width = window.innerWidth;
63 | canvas.height = options.barThickness * 5; // need space for shadow
64 |
65 | var ctx = canvas.getContext("2d");
66 | ctx.shadowBlur = options.shadowBlur;
67 | ctx.shadowColor = options.shadowColor;
68 |
69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
70 | for (var stop in options.barColors)
71 | lineGradient.addColorStop(stop, options.barColors[stop]);
72 | ctx.lineWidth = options.barThickness;
73 | ctx.beginPath();
74 | ctx.moveTo(0, options.barThickness / 2);
75 | ctx.lineTo(
76 | Math.ceil(currentProgress * canvas.width),
77 | options.barThickness / 2
78 | );
79 | ctx.strokeStyle = lineGradient;
80 | ctx.stroke();
81 | },
82 | createCanvas = function () {
83 | canvas = document.createElement("canvas");
84 | var style = canvas.style;
85 | style.position = "fixed";
86 | style.top = style.left = style.right = style.margin = style.padding = 0;
87 | style.zIndex = 100001;
88 | style.display = "none";
89 | if (options.className) canvas.classList.add(options.className);
90 | document.body.appendChild(canvas);
91 | addEvent(window, "resize", repaint);
92 | },
93 | topbar = {
94 | config: function (opts) {
95 | for (var key in opts)
96 | if (options.hasOwnProperty(key)) options[key] = opts[key];
97 | },
98 | show: function () {
99 | if (showing) return;
100 | showing = true;
101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
102 | if (!canvas) createCanvas();
103 | canvas.style.opacity = 1;
104 | canvas.style.display = "block";
105 | topbar.progress(0);
106 | if (options.autoRun) {
107 | (function loop() {
108 | progressTimerId = window.requestAnimationFrame(loop);
109 | topbar.progress(
110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
111 | );
112 | })();
113 | }
114 | },
115 | progress: function (to) {
116 | if (typeof to === "undefined") return currentProgress;
117 | if (typeof to === "string") {
118 | to =
119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
120 | ? currentProgress
121 | : 0) + parseFloat(to);
122 | }
123 | currentProgress = to > 1 ? 1 : to;
124 | repaint();
125 | return currentProgress;
126 | },
127 | hide: function () {
128 | if (!showing) return;
129 | showing = false;
130 | if (progressTimerId != null) {
131 | window.cancelAnimationFrame(progressTimerId);
132 | progressTimerId = null;
133 | }
134 | (function loop() {
135 | if (topbar.progress("+.1") >= 1) {
136 | canvas.style.opacity -= 0.05;
137 | if (canvas.style.opacity <= 0.05) {
138 | canvas.style.display = "none";
139 | fadeTimerId = null;
140 | return;
141 | }
142 | }
143 | fadeTimerId = window.requestAnimationFrame(loop);
144 | })();
145 | },
146 | };
147 |
148 | if (typeof module === "object" && typeof module.exports === "object") {
149 | module.exports = topbar;
150 | } else if (typeof define === "function" && define.amd) {
151 | define(function () {
152 | return topbar;
153 | });
154 | } else {
155 | this.topbar = topbar;
156 | }
157 | }.call(this, window, document));
158 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Mix.Config
2 |
3 | # log everything so we can debug
4 | config :logger, level: :info
5 |
6 | import_config "prod.secret.exs"
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/live_view_todo/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule LiveViewTodo.Repo do
2 | use Ecto.Repo,
3 | otp_app: :live_view_todo,
4 | adapter: Ecto.Adapters.SQLite3
5 | end
6 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |