├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── Procfile ├── README.md ├── assets ├── css │ ├── app.css │ ├── phoenix.css │ └── todomvc-app.css ├── js │ └── app.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── prod.secret.exs └── test.exs ├── coveralls.json ├── elixir_buildpack.config ├── lib ├── live_view_todo.ex ├── live_view_todo │ ├── application.ex │ ├── item.ex │ └── repo.ex ├── live_view_todo_web.ex └── live_view_todo_web │ ├── channels │ └── user_socket.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ ├── item_component.ex │ ├── page_live.ex │ ├── page_live.html.heex │ └── toolbar_component.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ └── layout │ │ ├── app.html.heex │ │ ├── live.html.heex │ │ └── root.html.heex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ └── layout_view.ex ├── live_view_todo_dev.db ├── live_view_todo_dev.db-shm ├── live_view_todo_dev.db-wal ├── live_view_todo_test.db ├── live_view_todo_test.db-shm ├── live_view_todo_test.db-wal ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ └── 20170606070700_create_items.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ └── robots.txt └── test ├── live_view_todo └── item_test.exs ├── live_view_todo_web ├── live │ └── page_live_test.exs └── views │ ├── error_view_test.exs │ └── layout_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex └── data_case.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | plugins: [Phoenix.LiveView.HTMLFormatter], 3 | inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], 4 | import_deps: [:ecto, :phoenix], 5 | subdirectories: ["priv/*/migrations"] 6 | ] 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: mix phx.server -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Phoenix LiveView Todo List Tutorial 4 | 5 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/phoenix-liveview-todo-list-tutorial/ci.yml?label=build&style=flat-square&branch=main) 6 | [![codecov.io](https://img.shields.io/codecov/c/github/dwyl/phoenix-liveview-todo-list-tutorial/master.svg?style=flat-square)](https://codecov.io/github/dwyl/phoenix-liveview-todo-list-tutorial?branch=master) 7 | [![Hex pm](https://img.shields.io/hexpm/v/phoenix_live_view.svg?style=flat-square)](https://hex.pm/packages/phoenix_live_view) 8 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/phoenix-liveview-todo-list-tutorial/issues) 9 | [![HitCount](https://hits.dwyl.com/dwyl/phoenix-liveview-todo-list-tutorial.svg)](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 | ![welcome-to-phoenix-liveview](https://user-images.githubusercontent.com/194400/95674046-99a26c00-0ba5-11eb-94e8-eec6840035a0.png) 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 |
319 | 1 item left 320 | 331 | 334 |
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 | ![before-adding-css](https://user-images.githubusercontent.com/194400/95677403-420ffa80-0bbd-11eb-9901-0604e08c6974.png) 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 | ![todo-list-ui](https://user-images.githubusercontent.com/194400/95678014-7f768700-0bc1-11eb-8728-5eae33cbb560.png) 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 |
832 | 841 |
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 | 948 | ``` 949 | 950 | With the following: 951 | 952 | ```elixir 953 | 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 | ![todo-items-create](https://user-images.githubusercontent.com/194400/96370930-718fab80-1157-11eb-9e8a-24b4548fcf1c.png) 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 | ![liveview-todo-delete](https://user-images.githubusercontent.com/194400/96378407-9b5ec780-1183-11eb-971d-94988352a0cf.gif) 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 | 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 | "
" 1262 | end 1263 | 1264 | test "update an item", %{conn: conn} do 1265 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"}) 1266 | 1267 | {:ok, view, _html} = live(conn, "/") 1268 | 1269 | assert render_submit(view, "update-item", %{"id" => item.id, "text" => "Learn more Elixir"}) =~ 1270 | "Learn more Elixir" 1271 | 1272 | updated_item = Item.get_item!(item.id) 1273 | assert updated_item.text == "Learn more Elixir" 1274 | end 1275 | ``` 1276 | 1277 | The first test ensures the form is displayed when the `edit-item` event is triggered 1278 | is sent to the LiveView. 1279 | The second test, make sure the item value is updated when the edit form is submitted. 1280 | 1281 | 1282 | ### UI enhancement 1283 | 1284 | We can make the UI a bit better by adding focus to the edited item using 1285 | [Hooks](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook) 1286 | 1287 | On the template add a new attribute `phx-hook`: 1288 | 1289 | ```html 1290 | 1299 | ``` 1300 | 1301 | Then in `app.js` add the following: 1302 | 1303 | ```js 1304 | function focusInput(input) { 1305 | let end = input.value.length; 1306 | input.setSelectionRange(end, end); 1307 | input.focus(); 1308 | } 1309 | 1310 | 1311 | let Hooks = {} 1312 | Hooks.FocusInputItem = { 1313 | mounted() { 1314 | focusInput(document.getElementById("update_todo")); 1315 | }, 1316 | updated() { 1317 | focusInput(document.getElementById("update_todo")); 1318 | } 1319 | } 1320 | ``` 1321 | 1322 | the function `focusInput` add the focus to the input and place the cursor 1323 | at the end of the text. 1324 | 1325 | We use this function in the `mounted` and `updated` hooks event, `mounted` for the 1326 | first time the input is displayed, then `updated` when the input is dispalayed 1327 | again for editing other items. 1328 | 1329 | Finally we need to pass our `Hooks` object to the socket: 1330 | 1331 | ```js 1332 | let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks }) 1333 | ``` 1334 | 1335 | 1336 | borrow from: https://github.com/dwyl/phoenix-todo-list-tutorial#8-edit-an-item 1337 | 1338 |
1339 | 1340 | ## 9. Footer Navigation 1341 | 1342 | In this section we'll update the footer links "All", "Active" and "Completed" 1343 | to make sure the `LiveView` displays only the `items` with the correct status. 1344 | 1345 | We first need to update the templates `lib/live_view_todo_web/live/page_live.html.heex` 1346 | to use the [`link`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) 1347 | component. This component allows `LiveView` to manage the navigation without having 1348 | to reload the page: 1349 | 1350 | ```html 1351 | <.link patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "all"})}>All 1352 | <.link patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "active"})}>Active 1353 | <.link patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "completed"})}>Completed 1354 | ``` 1355 | 1356 | The `filter_by` query parameters can have the "all", "active" or "completed" value. 1357 | 1358 | We then define a new `handle_params` function in `lib/live_view_todo_web/live/page_live.ex`: 1359 | 1360 | ```elixir 1361 | @impl true 1362 | def handle_params(params, _url, socket) do 1363 | items = Item.list_items() 1364 | 1365 | case params["filter_by"] do 1366 | "completed" -> 1367 | completed = Enum.filter(items, &(&1.status == 1)) 1368 | {:noreply, assign(socket, items: completed, tab: "completed")} 1369 | 1370 | "active" -> 1371 | active = Enum.filter(items, &(&1.status == 0)) 1372 | {:noreply, assign(socket, items: active, tab: "active")} 1373 | 1374 | _ -> 1375 | {:noreply, assign(socket, items: items, tab: "all")} 1376 | end 1377 | end 1378 | ``` 1379 | 1380 | `live_patch` links will call this function to handle the navigation. 1381 | The `filter_by` value is checked and the list of `items` is filtered. 1382 | The socket is then updated using `assign` with the filter list. 1383 | 1384 | Finally we can add a test to make sure only the correct `items` are displayed. 1385 | In `test/live_view_todo_web/live/page_live_test.exs` add: 1386 | 1387 | ```elixir 1388 | test "Filter item", %{conn: conn} do 1389 | {:ok, item1} = Item.create_item(%{"text" => "Learn Elixir"}) 1390 | {:ok, _item2} = Item.create_item(%{"text" => "Learn Phoenix"}) 1391 | 1392 | {:ok, view, _html} = live(conn, "/") 1393 | assert render_click(view, :toggle, %{"id" => item1.id, "value" => 1}) =~ "completed" 1394 | 1395 | # list only completed items 1396 | {:ok, view, _html} = live(conn, "/?filter_by=completed") 1397 | assert render(view) =~ "Learn Elixir" 1398 | refute render(view) =~ "Learn Phoenix" 1399 | 1400 | # list only active items 1401 | {:ok, view, _html} = live(conn, "/?filter_by=active") 1402 | refute render(view) =~ "Learn Elixir" 1403 | assert render(view) =~ "Learn Phoenix" 1404 | 1405 | # list all items 1406 | {:ok, view, _html} = live(conn, "/?filter_by=all") 1407 | assert render(view) =~ "Learn Elixir" 1408 | assert render(view) =~ "Learn Phoenix" 1409 | end 1410 | ``` 1411 | 1412 | Two items are created and one is marked as completed. 1413 | The view is then rendered multiple times to verify the `filter_by` param 1414 | display the correct item 1415 | 1416 | Borrow from: 1417 | https://github.com/dwyl/phoenix-todo-list-tutorial#9-footer-navigation 1418 | 1419 |
1420 | 1421 | ## 10. Clear Completed 1422 | 1423 | To clear completed items the liveview needs to udpate all items with a status 1424 | defined as 1 to 2. 1425 | 1426 | First we update the "clear completd" button to use the `phx-click` binding to 1427 | create a new event, in `lib/live_view_todo_web/live/page_live.html.heex` update 1428 | the button to: 1429 | 1430 | ```html 1431 | 1432 | ``` 1433 | 1434 | In `lib/live_view_todo_web/live/page_live.ex` when then define a new `handle_event` function: 1435 | 1436 | ```elixir 1437 | @impl true 1438 | def handle_event("clear-completed", _data, socket) do 1439 | Item.clear_completed() 1440 | items = Item.list_items() 1441 | {:noreply, assign(socket, items: items)} 1442 | end 1443 | ``` 1444 | 1445 | In `lib/live_view_todo/item.ex` we update the list_items function: 1446 | 1447 | ```elixir 1448 | def list_items do 1449 | Item 1450 | |> order_by(desc: :inserted_at) 1451 | |> where([a], is_nil(a.status) or a.status != 2) 1452 | |> Repo.all() 1453 | end 1454 | ``` 1455 | 1456 | 1457 | Finally we need to define `Item.clear_completed/0` function in `lib/live_view_todo/item.ex`: 1458 | 1459 | ```elixir 1460 | def clear_completed() do 1461 | completed_items = from(i in Item, where: i.status == 1) 1462 | Repo.update_all(completed_items, set: [status: 2]) 1463 | end 1464 | ``` 1465 | 1466 | We can also add the following test to make sure completed items are removed: 1467 | 1468 | ```elixir 1469 | test "clear completed items", %{conn: conn} do 1470 | {:ok, item1} = Item.create_item(%{"text" => "Learn Elixir"}) 1471 | {:ok, _item2} = Item.create_item(%{"text" => "Learn Phoenix"}) 1472 | 1473 | # complete item1 1474 | {:ok, view, _html} = live(conn, "/") 1475 | assert render(view) =~ "Learn Elixir" 1476 | assert render(view) =~ "Learn Phoenix" 1477 | 1478 | assert render_click(view, :toggle, %{"id" => item1.id, "value" => 1}) 1479 | 1480 | view = render_click(view, "clear-completed", %{}) 1481 | assert view =~ "Learn Phoenix" 1482 | refute view =~ "Learn Elixir" 1483 | end 1484 | ``` 1485 | 1486 | 1487 | Borrow from: 1488 | https://github.com/dwyl/phoenix-todo-list-tutorial#10-clear-completed 1489 | 1490 | 1491 | ## 11. Live Components 1492 | 1493 | LiveView provides the Live Components feature to group UI state and events. 1494 | In this section we're going to see how to use component for items. 1495 | 1496 | The first step is to create a new file: 1497 | `lib/live_view_todo_web/live/item_component.ex` 1498 | 1499 | With the following code: 1500 | 1501 | ```elixir 1502 | defmodule LiveViewTodoWeb.ItemComponent do 1503 | use LiveViewTodoWeb, :live_component 1504 | alias LiveViewTodo.Item 1505 | 1506 | attr(:items, :list, default: []) 1507 | 1508 | def render(assigns) do 1509 | ~H""" 1510 |
    1511 | <%= for item <- @items do %> 1512 | <%= if item.id == @editing do %> 1513 | 1514 | 1523 | 1524 | 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 | 59 | """ 60 | end 61 | 62 | @impl true 63 | def handle_event("toggle", data, socket) do 64 | status = if Map.has_key?(data, "value"), do: 1, else: 0 65 | item = Item.get_item!(Map.get(data, "id")) 66 | 67 | Item.update_item(item, %{id: item.id, status: status}) 68 | 69 | socket = assign(socket, items: Item.list_items(), active: %Item{}) 70 | LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns) 71 | 72 | {:noreply, socket} 73 | end 74 | 75 | @impl true 76 | def handle_event("edit-item", data, socket) do 77 | {:noreply, assign(socket, editing: String.to_integer(data["id"]))} 78 | end 79 | 80 | @impl true 81 | def handle_event("update-item", %{"id" => item_id, "text" => text}, socket) do 82 | current_item = Item.get_item!(item_id) 83 | Item.update_item(current_item, %{text: text}) 84 | items = Item.list_items() 85 | socket = assign(socket, items: items, editing: nil) 86 | LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns) 87 | {:noreply, socket} 88 | end 89 | 90 | @impl true 91 | def handle_event("delete", data, socket) do 92 | Item.delete_item(Map.get(data, "id")) 93 | socket = assign(socket, items: Item.list_items(), active: %Item{}) 94 | LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns) 95 | {:noreply, socket} 96 | end 97 | 98 | def checked?(item) do 99 | not is_nil(item.status) and item.status > 0 100 | end 101 | 102 | def completed?(item) do 103 | if not is_nil(item.status) and item.status > 0, do: "completed", else: "" 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /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/live/page_live.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Todos

4 |
5 | 14 |
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 | -------------------------------------------------------------------------------- /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 |
10 | 11 | 12 | <%= Enum.count(Enum.filter(@items, fn i -> i.status != 1 end)) %> 13 | 14 | <%= if Enum.count(Enum.filter(@items, fn i -> i.status != 1 end)) == 1 do %> 15 | item 16 | <% else %> 17 | items 18 | <% end %> 19 | left 20 | 21 |
    22 |
  • 23 | <.link 24 | class={if @tab == "all", do: "selected", else: ""} 25 | patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "all"})} 26 | > 27 | All 28 | 29 |
  • 30 |
  • 31 | <.link 32 | class={if @tab == "active", do: "selected", else: ""} 33 | patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "active"})} 34 | > 35 | Active 36 | 37 |
  • 38 |
  • 39 | <.link 40 | class={if @tab == "completed", do: "selected", else: ""} 41 | patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "completed"})} 42 | > 43 | Completed 44 | 45 |
  • 46 |
47 | 50 |
51 | """ 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/live_view_todo_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /lib/live_view_todo_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <%= @inner_content %> 3 |
4 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /lib/live_view_todo_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveViewTodoWeb.LayoutView do 2 | use LiveViewTodoWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /live_view_todo_dev.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/live_view_todo_dev.db -------------------------------------------------------------------------------- /live_view_todo_dev.db-shm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/live_view_todo_dev.db-shm -------------------------------------------------------------------------------- /live_view_todo_dev.db-wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/live_view_todo_dev.db-wal -------------------------------------------------------------------------------- /live_view_todo_test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/live_view_todo_test.db -------------------------------------------------------------------------------- /live_view_todo_test.db-shm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/live_view_todo_test.db-shm -------------------------------------------------------------------------------- /live_view_todo_test.db-wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/live_view_todo_test.db-wal -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, 3 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.9", "e8d3364f310da6ce6463c3dd20cf90ae7bbecbf6c5203b98bf9b48035592649b", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9dcab3d0f3038621f1601f13539e7a9ee99843862e66ad62827b0c42b2f58a54"}, 4 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 5 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 6 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 8 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 9 | "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, 10 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 11 | "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, 12 | "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, 13 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"}, 14 | "elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"}, 15 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 16 | "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, 17 | "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, 18 | "exqlite": {:hex, :exqlite, "0.19.0", "0f3ee29e35bed38552dd0ed59600aa81c78f867f5b5ff0e17d330148e0465483", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "55a8fbb0443f03d4a256e3458bd1203eff5037a6624b76460eaaa9080f462b06"}, 19 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 20 | "floki": {:hex, :floki, "0.36.0", "544d5dd8a3107f660633226b5805e47c2ac1fabd782fae86e3b22b02849b20f9", [:mix], [], "hexpm", "ab1ca4b1efb0db00df9a8e726524e2c85be88cf65ac092669186e1674d34d74c"}, 21 | "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, 22 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 23 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 24 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 25 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 26 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 27 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 28 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 29 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 30 | "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, 31 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.5.0", "1a1f841ccda19b15f1d82968840a5b895c5f687b6734e430e4b2dbe035ca1837", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "13990570fde09e16959ef214501fe2813e1192d62ca753ec8798980580436f94"}, 32 | "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, 33 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.0", "0b3158b5b198aa444473c91d23d79f52fb077e807ffad80dacf88ce078fa8df2", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "87785a54474fed91a67a1227a741097eb1a42c2e49d3c0d098b588af65cd410d"}, 34 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.1", "6ab463cf43938ee9906067b33c8d66782343de4280a70084cd5617accc6345a8", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "e8467d308b61f294f68afe12c81bf585584c7ceed40ec8adde88ec176d480a78"}, 35 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.5", "6e730595e8e9b8c5da230a814e557768828fd8dfeeb90377d2d8dbb52d4ec00a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eaa0dd3cfb9bd7fb949b88217df9f25aed915e986a28ad5c8a0d054e7ca9d3"}, 36 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 37 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 38 | "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, 39 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 40 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.0", "3ae9369c60641084363b08fe90267cbdd316df57e3557ea522114b30b63256ea", [:mix], [{:cowboy, "~> 2.7.0 or ~> 2.8.0 or ~> 2.9.0 or ~> 2.10.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d85444fb8aa1f2fc62eabe83bbe387d81510d773886774ebdcb429b3da3c1a4a"}, 41 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 42 | "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, 43 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 44 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 45 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 46 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, 47 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 48 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 49 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 50 | "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-todo-list-tutorial/f767634615e960cebc2865895d2d68ceebcfc8f7/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/live_view_todo_web/live/page_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveViewTodoWeb.PageLiveTest do 2 | use LiveViewTodoWeb.ConnCase 3 | alias LiveViewTodo.Item 4 | import Phoenix.LiveViewTest 5 | 6 | test "disconnected and connected render", %{conn: conn} do 7 | {:ok, page_live, disconnected_html} = live(conn, "/") 8 | assert disconnected_html =~ "Todo" 9 | assert render(page_live) =~ "Todo" 10 | end 11 | 12 | test "connect and create a todo item", %{conn: conn} do 13 | {:ok, view, _html} = live(conn, "/") 14 | assert render_submit(view, :create, %{"text" => "Learn Elixir"}) =~ "Learn Elixir" 15 | end 16 | 17 | test "toggle an item", %{conn: conn} do 18 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"}) 19 | assert item.status == 0 20 | {:ok, view, _html} = live(conn, "/") 21 | 22 | assert view |> element("#item-#{item.id}") |> render_click() 23 | end 24 | 25 | test "delete an item", %{conn: conn} do 26 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"}) 27 | assert item.status == 0 28 | 29 | {:ok, view, _html} = live(conn, "/") 30 | assert view |> element("#delete-item-#{item.id}") |> render_click() 31 | 32 | updated_item = Item.get_item!(item.id) 33 | assert updated_item.status == 2 34 | end 35 | 36 | test "edit item", %{conn: conn} do 37 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"}) 38 | 39 | {:ok, view, _html} = live(conn, "/") 40 | assert view |> element("#edit-item-#{item.id}") |> render_click() 41 | end 42 | 43 | test "update an item", %{conn: conn} do 44 | {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"}) 45 | 46 | {:ok, view, _html} = live(conn, "/") 47 | assert view |> element("#edit-item-#{item.id}") |> render_click() 48 | 49 | assert view 50 | |> form("#form-update", %{"id" => item.id, "text" => "Learn more Elixir"}) 51 | |> render_submit() 52 | 53 | updated_item = Item.get_item!(item.id) 54 | assert updated_item.text == "Learn more Elixir" 55 | end 56 | 57 | test "Filter item", %{conn: conn} do 58 | {:ok, _item1} = Item.create_item(%{"text" => "Learn Elixir", "status" => 1}) 59 | {:ok, _item2} = Item.create_item(%{"text" => "Learn Phoenix"}) 60 | 61 | # list only completed items 62 | {:ok, view, _html} = live(conn, "/?filter_by=completed") 63 | assert render(view) =~ "Learn Elixir" 64 | refute render(view) =~ "Learn Phoenix" 65 | 66 | # list only active items 67 | {:ok, view, _html} = live(conn, "/?filter_by=active") 68 | refute render(view) =~ "Learn Elixir" 69 | assert render(view) =~ "Learn Phoenix" 70 | 71 | # list all items 72 | {:ok, view, _html} = live(conn, "/?filter_by=all") 73 | assert render(view) =~ "Learn Elixir" 74 | assert render(view) =~ "Learn Phoenix" 75 | end 76 | 77 | test "clear completed items", %{conn: conn} do 78 | {:ok, _item1} = Item.create_item(%{"text" => "Learn Elixir", "status" => 1}) 79 | {:ok, _item2} = Item.create_item(%{"text" => "Learn Phoenix"}) 80 | 81 | # complete item1 82 | {:ok, view, _html} = live(conn, "/") 83 | assert render(view) =~ "Learn Elixir" 84 | assert render(view) =~ "Learn Phoenix" 85 | 86 | view = render_click(view, "clear-completed", %{}) 87 | assert view =~ "Learn Phoenix" 88 | refute view =~ "Learn Elixir" 89 | end 90 | 91 | # This test is just to ensure coverage of the handle_info/2 function 92 | # It's not required but we like to have 100% coverage. 93 | # https://stackoverflow.com/a/60852290/1148249 94 | test "handle_info/2", %{conn: conn} do 95 | {:ok, view, _html} = live(conn, "/") 96 | {:ok, item} = Item.create_item(%{"text" => "Always Learning"}) 97 | send(view.pid, %{event: "update", payload: %{items: Item.list_items()}}) 98 | assert render(view) =~ item.text 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(LiveViewTodo.Repo, :manual) 3 | --------------------------------------------------------------------------------