From 71872abd0d1937c56c9f91e1386805bb0d491486 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 19 Oct 2022 21:49:03 +0100 Subject: [PATCH 01/33] Test npm i and examples Run the current examples with node Add lock package file to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 6704566..cab290e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +package-lock.json From c4f97433b89751d808d77c85d02975b0e291c033 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Fri, 21 Oct 2022 11:13:59 +0100 Subject: [PATCH 02/33] Create phoenix application - Create new phx app - Add Tailwind to the phoenix application --- .formatter.exs | 6 + .gitignore | 35 ++++ README.md | 16 +- assets/css/app.css | 124 ++++++++++++++ assets/css/phoenix.css | 101 +++++++++++ assets/js/app.js | 44 +++++ assets/tailwind.config.js | 22 +++ assets/vendor/topbar.js | 157 ++++++++++++++++++ config/config.exs | 51 ++++++ config/dev.exs | 75 +++++++++ config/prod.exs | 49 ++++++ config/runtime.exs | 65 ++++++++ config/test.exs | 27 +++ lib/app.ex | 9 + lib/app/application.ex | 36 ++++ lib/app/repo.ex | 5 + lib/app_web.ex | 108 ++++++++++++ lib/app_web/controllers/page_controller.ex | 7 + lib/app_web/endpoint.ex | 46 +++++ lib/app_web/router.ex | 27 +++ lib/app_web/telemetry.ex | 71 ++++++++ lib/app_web/templates/layout/app.html.heex | 5 + lib/app_web/templates/layout/live.html.heex | 11 ++ lib/app_web/templates/layout/root.html.heex | 22 +++ lib/app_web/templates/page/index.html.heex | 3 + lib/app_web/views/error_helpers.ex | 30 ++++ lib/app_web/views/error_view.ex | 16 ++ lib/app_web/views/layout_view.ex | 7 + lib/app_web/views/page_view.ex | 3 + mix.exs | 68 ++++++++ mix.lock | 33 ++++ priv/repo/migrations/.formatter.exs | 4 + priv/repo/seeds.exs | 11 ++ priv/static/favicon.ico | Bin 0 -> 1258 bytes priv/static/images/phoenix.png | Bin 0 -> 13900 bytes priv/static/robots.txt | 5 + .../controllers/page_controller_test.exs | 8 + test/app_web/views/error_view_test.exs | 14 ++ test/app_web/views/layout_view_test.exs | 8 + test/app_web/views/page_view_test.exs | 3 + test/support/conn_case.ex | 38 +++++ test/support/data_case.ex | 58 +++++++ test/test_helper.exs | 2 + 43 files changed, 1429 insertions(+), 1 deletion(-) create mode 100644 .formatter.exs create mode 100644 assets/css/app.css create mode 100644 assets/css/phoenix.css create mode 100644 assets/js/app.js create mode 100644 assets/tailwind.config.js create mode 100644 assets/vendor/topbar.js create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/app.ex create mode 100644 lib/app/application.ex create mode 100644 lib/app/repo.ex create mode 100644 lib/app_web.ex create mode 100644 lib/app_web/controllers/page_controller.ex create mode 100644 lib/app_web/endpoint.ex create mode 100644 lib/app_web/router.ex create mode 100644 lib/app_web/telemetry.ex create mode 100644 lib/app_web/templates/layout/app.html.heex create mode 100644 lib/app_web/templates/layout/live.html.heex create mode 100644 lib/app_web/templates/layout/root.html.heex create mode 100644 lib/app_web/templates/page/index.html.heex create mode 100644 lib/app_web/views/error_helpers.ex create mode 100644 lib/app_web/views/error_view.ex create mode 100644 lib/app_web/views/layout_view.ex create mode 100644 lib/app_web/views/page_view.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/seeds.exs create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/images/phoenix.png create mode 100644 priv/static/robots.txt create mode 100644 test/app_web/controllers/page_controller_test.exs create mode 100644 test/app_web/views/error_view_test.exs create mode 100644 test/app_web/views/layout_view_test.exs create mode 100644 test/app_web/views/page_view_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..5b550b5 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], + import_deps: [:ecto, :phoenix], + subdirectories: ["priv/*/migrations"] +] diff --git a/.gitignore b/.gitignore index cab290e..5992bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,38 @@ dist .tern-port package-lock.json + +# Phoenix gitignore values +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +app-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ diff --git a/README.md b/README.md index d3c4261..d601da3 100644 --- a/README.md +++ b/README.md @@ -88,4 +88,18 @@ Once you have a basic understanding of how Alpine.js's directives work. checkout our Stopwatch example: https://dwyl.github.io/learn-alpine.js/stopwatch.html -Code: [**`stopwatch.html`**](https://github.com/dwyl/learn-alpine.js/blob/main/stopwatch.html) \ No newline at end of file +Code: [**`stopwatch.html`**](https://github.com/dwyl/learn-alpine.js/blob/main/stopwatch.html) + +### Drag and drop + +Along the nodejs application used for the sotopwatch example, +we have created a Phoenix application to test see how drag-and-drop +can be implemented using Alpinejs. + +```sh +mix phx.new . --app app --no-dashboard --no-gettext --no-mailer +``` + +Then we install Tailwind, see https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix + + diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..04efcc9 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,124 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ + +/* Alerts and form errors used by phx.new */ +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert-info { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.alert p { + margin-bottom: 0; +} +.alert:empty { + display: none; +} +.invalid-feedback { + color: #a94442; + display: block; + margin: -1rem 0 2rem; +} + +/* LiveView specific classes for your customization */ +.phx-no-feedback.invalid-feedback, +.phx-no-feedback .invalid-feedback { + display: none; +} + +.phx-click-loading { + opacity: 0.5; + transition: opacity 1s ease-out; +} + +.phx-loading{ + cursor: wait; +} + +.phx-modal { + opacity: 1!important; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); +} + +.phx-modal-content { + background-color: #fefefe; + margin: 15vh auto; + padding: 20px; + border: 1px solid #888; + width: 80%; +} + +.phx-modal-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.phx-modal-close:hover, +.phx-modal-close:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +.fade-in-scale { + animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; +} + +.fade-out-scale { + animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; +} + +.fade-in { + animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; +} +.fade-out { + animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; +} + +@keyframes fade-in-scale-keys{ + 0% { scale: 0.95; opacity: 0; } + 100% { scale: 1.0; opacity: 1; } +} + +@keyframes fade-out-scale-keys{ + 0% { scale: 1.0; opacity: 1; } + 100% { scale: 0.95; opacity: 0; } +} + +@keyframes fade-in-keys{ + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes fade-out-keys{ + 0% { opacity: 1; } + 100% { opacity: 0; } +} + diff --git a/assets/css/phoenix.css b/assets/css/phoenix.css new file mode 100644 index 0000000..0d59050 --- /dev/null +++ b/assets/css/phoenix.css @@ -0,0 +1,101 @@ +/* Includes some default style for the starter application. + * This can be safely deleted to start fresh. + */ + +/* Milligram v1.4.1 https://milligram.github.io + * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license + */ + +*,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;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='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-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 .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']: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,input[type='week']:focus,input:not([type]):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,')}select[multiple]{background:none;height:auto}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}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.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-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.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{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;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}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} + +/* General style */ +h1{font-size: 3.6rem; line-height: 1.25} +h2{font-size: 2.8rem; line-height: 1.3} +h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} +h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} +h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} +h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} +pre{padding: 1em;} + +.container{ + margin: 0 auto; + max-width: 80.0rem; + padding: 0 2.0rem; + position: relative; + width: 100% +} +select { + width: auto; +} + +/* Phoenix promo and logo */ +.phx-hero { + text-align: center; + border-bottom: 1px solid #e3e3e3; + background: #eee; + border-radius: 6px; + padding: 3em 3em 1em; + margin-bottom: 3rem; + font-weight: 200; + font-size: 120%; +} +.phx-hero input { + background: #ffffff; +} +.phx-logo { + min-width: 300px; + margin: 1rem; + display: block; +} +.phx-logo img { + width: auto; + display: block; +} + +/* Headers */ +header { + width: 100%; + background: #fdfdfd; + border-bottom: 1px solid #eaeaea; + margin-bottom: 2rem; +} +header section { + align-items: center; + display: flex; + flex-direction: column; + justify-content: space-between; +} +header section :first-child { + order: 2; +} +header section :last-child { + order: 1; +} +header nav ul, +header nav li { + margin: 0; + padding: 0; + display: block; + text-align: right; + white-space: nowrap; +} +header nav ul { + margin: 1rem; + margin-top: 0; +} +header nav a { + display: block; +} + +@media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ + header section { + flex-direction: row; + } + header nav ul { + margin: 1rem; + } + .phx-logo { + flex-basis: 527px; + margin: 2rem 1rem; + } +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..bf203ba --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,44 @@ +// We import the CSS which is extracted to its own file by esbuild. +// Remove this line if you add a your own CSS build pipeline (e.g postcss). + +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", info => topbar.show()) +window.addEventListener("phx:page-loading-stop", info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..76fe451 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,22 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +let plugin = require('tailwindcss/plugin') + +module.exports = { + content: [ + './js/**/*.js', + '../lib/*_web.ex', + '../lib/*_web/**/*.*ex' + ], + theme: { + extend: {}, + }, + plugins: [ + require('@tailwindcss/forms'), + plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), + plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), + plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), + plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) + ] +} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..1f62209 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,157 @@ +/** + * @license MIT + * topbar 1.0.0, 2021-01-06 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + progressTimerId, + fadeTimerId, + currentProgress, + showing, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function () { + if (showing) return; + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..4fa1e2b --- /dev/null +++ b/config/config.exs @@ -0,0 +1,51 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :app, + ecto_repos: [App.Repo] + +# Configures the endpoint +config :app, AppWeb.Endpoint, + url: [host: "localhost"], + render_errors: [view: AppWeb.ErrorView, accepts: ~w(html json), layout: false], + pubsub_server: App.PubSub, + live_view: [signing_salt: "RmlS3YZ/"] + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.14.29", + default: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +config :tailwind, + version: "3.2.0", + default: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..74e3914 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,75 @@ +import Config + +# Configure your database +config :app, App.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "app_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with esbuild to bundle .js and .css sources. +config :app, AppWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "aMh0TI5FqAjFd9Xa+kRAlNfq1OzVb6NmiciLcpgiuU9mpCYjc78L+5cPte+Bqo0m", + watchers: [ + # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Note that this task requires Erlang/OTP 20 or later. +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :app, AppWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"lib/app_web/(live|views)/.*(ex)$", + ~r"lib/app_web/templates/.*(eex)$" + ] + ] + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d5f74e9 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,49 @@ +import Config + +# For production, don't forget to configure the url host +# to something meaningful, Phoenix uses this information +# when generating URLs. +# +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix phx.digest` task, +# which you should run after static files are built and +# before starting your production server. +config :app, AppWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info + +# ## SSL Support +# +# To get SSL working, you will need to add the `https` key +# to the previous section and set your `:url` port to 443: +# +# config :app, AppWeb.Endpoint, +# ..., +# url: [host: "example.com", port: 443], +# https: [ +# ..., +# port: 443, +# cipher_suite: :strong, +# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), +# certfile: System.get_env("SOME_APP_SSL_CERT_PATH") +# ] +# +# The `cipher_suite` is set to `:strong` to support only the +# latest and more secure SSL ciphers. This means old browsers +# and clients may not be supported. You can set it to +# `:compatible` for wider support. +# +# `:keyfile` and `:certfile` expect an absolute path to the key +# and cert in disk or a relative path inside priv, for example +# "priv/ssl/server.key". For all supported SSL configuration +# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 +# +# We also recommend setting `force_ssl` in your endpoint, ensuring +# no data is ever sent via http, always redirecting to https: +# +# config :app, AppWeb.Endpoint, +# force_ssl: [hsts: true] +# +# Check `Plug.SSL` for all available options in `force_ssl`. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..f930961 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,65 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/app start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :app, AppWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] + + config :app, App.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :app, AppWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..0b59e37 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,27 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :app, App.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "app_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 10 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :app, AppWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "9ww3quhVNS/QGlR90nuvRsp4mo0iUNeQe8BNwsu7hL54c58IhSe3KDgQfU50WJvS", + server: false + +# Print only warnings and errors during test +config :logger, level: :warn + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/lib/app.ex b/lib/app.ex new file mode 100644 index 0000000..a10dc06 --- /dev/null +++ b/lib/app.ex @@ -0,0 +1,9 @@ +defmodule App do + @moduledoc """ + App keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/app/application.ex b/lib/app/application.ex new file mode 100644 index 0000000..7b9240f --- /dev/null +++ b/lib/app/application.ex @@ -0,0 +1,36 @@ +defmodule App.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the Ecto repository + App.Repo, + # Start the Telemetry supervisor + AppWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: App.PubSub}, + # Start the Endpoint (http/https) + AppWeb.Endpoint + # Start a worker by calling: App.Worker.start_link(arg) + # {App.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: App.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + AppWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/app/repo.ex b/lib/app/repo.ex new file mode 100644 index 0000000..857bd3f --- /dev/null +++ b/lib/app/repo.ex @@ -0,0 +1,5 @@ +defmodule App.Repo do + use Ecto.Repo, + otp_app: :app, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/app_web.ex b/lib/app_web.ex new file mode 100644 index 0000000..a25a25b --- /dev/null +++ b/lib/app_web.ex @@ -0,0 +1,108 @@ +defmodule AppWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use AppWeb, :controller + use AppWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: AppWeb + + import Plug.Conn + alias AppWeb.Router.Helpers, as: Routes + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/app_web/templates", + namespace: AppWeb + + import Phoenix.Component + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] + + # Include shared imports and aliases for views + unquote(view_helpers()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {AppWeb.LayoutView, "live.html"} + + unquote(view_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + + def component do + quote do + use Phoenix.Component + + unquote(view_helpers()) + end + end + + def router do + quote do + use Phoenix.Router + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + import Phoenix.LiveView.Helpers + + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + + import AppWeb.ErrorHelpers + alias AppWeb.Router.Helpers, as: Routes + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/app_web/controllers/page_controller.ex b/lib/app_web/controllers/page_controller.ex new file mode 100644 index 0000000..2941548 --- /dev/null +++ b/lib/app_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule AppWeb.PageController do + use AppWeb, :controller + + def index(conn, _params) do + render(conn, "index.html") + end +end diff --git a/lib/app_web/endpoint.ex b/lib/app_web/endpoint.ex new file mode 100644 index 0000000..147d14d --- /dev/null +++ b/lib/app_web/endpoint.ex @@ -0,0 +1,46 @@ +defmodule AppWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :app + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_app_key", + signing_salt: "V0P9A9Ds" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :app, + gzip: false, + only: ~w(assets fonts images favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :app + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug AppWeb.Router +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex new file mode 100644 index 0000000..fd6c64c --- /dev/null +++ b/lib/app_web/router.ex @@ -0,0 +1,27 @@ +defmodule AppWeb.Router do + use AppWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {AppWeb.LayoutView, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", AppWeb do + pipe_through :browser + + get "/", PageController, :index + end + + # Other scopes may use custom stacks. + # scope "/api", AppWeb do + # pipe_through :api + # end +end diff --git a/lib/app_web/telemetry.ex b/lib/app_web/telemetry.ex new file mode 100644 index 0000000..516863e --- /dev/null +++ b/lib/app_web/telemetry.ex @@ -0,0 +1,71 @@ +defmodule AppWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("app.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("app.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("app.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("app.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("app.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {AppWeb, :count_users, []} + ] + end +end diff --git a/lib/app_web/templates/layout/app.html.heex b/lib/app_web/templates/layout/app.html.heex new file mode 100644 index 0000000..169aed9 --- /dev/null +++ b/lib/app_web/templates/layout/app.html.heex @@ -0,0 +1,5 @@ +
+ + + <%= @inner_content %> +
diff --git a/lib/app_web/templates/layout/live.html.heex b/lib/app_web/templates/layout/live.html.heex new file mode 100644 index 0000000..1829aab --- /dev/null +++ b/lib/app_web/templates/layout/live.html.heex @@ -0,0 +1,11 @@ +
+ + + + + <%= @inner_content %> +
diff --git a/lib/app_web/templates/layout/root.html.heex b/lib/app_web/templates/layout/root.html.heex new file mode 100644 index 0000000..072c371 --- /dev/null +++ b/lib/app_web/templates/layout/root.html.heex @@ -0,0 +1,22 @@ + + + + + + + + <%= live_title_tag(assigns[:page_title] || "App", suffix: " · Phoenix Framework") %> + + + + +
+ <%= @inner_content %> + + diff --git a/lib/app_web/templates/page/index.html.heex b/lib/app_web/templates/page/index.html.heex new file mode 100644 index 0000000..bec4e92 --- /dev/null +++ b/lib/app_web/templates/page/index.html.heex @@ -0,0 +1,3 @@ +

+ Hello TailWorld! +

diff --git a/lib/app_web/views/error_helpers.ex b/lib/app_web/views/error_helpers.ex new file mode 100644 index 0000000..b3a7c1b --- /dev/null +++ b/lib/app_web/views/error_helpers.ex @@ -0,0 +1,30 @@ +defmodule AppWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + use Phoenix.HTML + + @doc """ + Generates tag for inlined form input errors. + """ + def error_tag(form, field) do + Enum.map(Keyword.get_values(form.errors, field), fn error -> + content_tag(:span, translate_error(error), + class: "invalid-feedback", + phx_feedback_for: input_name(form, field) + ) + end) + end + + @doc """ + Translates an error message. + """ + def translate_error({msg, opts}) do + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/app_web/views/error_view.ex b/lib/app_web/views/error_view.ex new file mode 100644 index 0000000..a6651a5 --- /dev/null +++ b/lib/app_web/views/error_view.ex @@ -0,0 +1,16 @@ +defmodule AppWeb.ErrorView do + use AppWeb, :view + + # If you want to customize a particular status code + # for a certain format, you may uncomment below. + # def render("500.html", _assigns) do + # "Internal Server Error" + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.html" becomes + # "Not Found". + def template_not_found(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/app_web/views/layout_view.ex b/lib/app_web/views/layout_view.ex new file mode 100644 index 0000000..69e85d2 --- /dev/null +++ b/lib/app_web/views/layout_view.ex @@ -0,0 +1,7 @@ +defmodule AppWeb.LayoutView do + use AppWeb, :view + + # Phoenix LiveDashboard is available only in development by default, + # so we instruct Elixir to not warn if the dashboard route is missing. + @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} +end diff --git a/lib/app_web/views/page_view.ex b/lib/app_web/views/page_view.ex new file mode 100644 index 0000000..8ba34d2 --- /dev/null +++ b/lib/app_web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule AppWeb.PageView do + use AppWeb, :view +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..4757f4b --- /dev/null +++ b/mix.exs @@ -0,0 +1,68 @@ +defmodule App.MixProject do + use Mix.Project + + def project do + [ + app: :app, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: Mix.compilers(), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {App.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.6.14"}, + {:phoenix_ecto, "~> 4.4"}, + {:ecto_sql, "~> 3.6"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 3.0"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 0.18"}, + {:floki, ">= 0.30.0", only: :test}, + {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, + {:telemetry_metrics, "~> 0.6"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:plug_cowboy, "~> 2.5"}, + {:tailwind, "~> 0.1", runtime: Mix.env() == :dev} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..d25872a --- /dev/null +++ b/mix.lock @@ -0,0 +1,33 @@ +%{ + "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "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"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 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", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"}, + "ecto_sql": {:hex, :ecto_sql, "3.9.0", "2bb21210a2a13317e098a420a8c1cc58b0c3421ab8e3acfa96417dab7817918c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.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", "a8f3f720073b8b1ac4c978be25fa7960ed7fd44997420c304a4a2e200b596453"}, + "esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"}, + "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "phoenix": {:hex, :phoenix, "1.6.14", "57678366dc1d5bad49832a0fc7f12c2830c10d3eacfad681bfe9602cd4445f04", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d48c0da00b3d4cd1aad6055387917491af9f6e1f1e96cedf6c6b7998df9dba26"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.2", "635cf07de947235deb030cd6b776c71a3b790ab04cebf526aa8c879fe17c7784", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6 or ~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "da287a77327e996cc166e4c440c3ad5ab33ccdb151b91c793209b39ebbce5b75"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {: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", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, +} diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..fe3037b --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# App.Repo.insert!(%App.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..73de524aaadcf60fbe9d32881db0aa86b58b5cb9 GIT binary patch literal 1258 zcmbtUO>fgM7{=qN=;Mz_82;lvPEdVaxv-<-&=sZLwab?3I zBP>U*&(Hv<5n@9ZQ$vhg#|u$Zmtq8BV;+W*7(?jOx-{r?#TE&$Sdq77MbdJjD5`-q zMm_z(jLv3t>5NhzK{%aG(Yudfpjd3AFdKe2U7&zdepTe>^s(@!&0X8TJ`h+-I?84Ml# literal 0 HcmV?d00001 diff --git a/priv/static/images/phoenix.png b/priv/static/images/phoenix.png new file mode 100644 index 0000000000000000000000000000000000000000..9c81075f63d2151e6f40e9aa66f665749a87cc6a GIT binary patch literal 13900 zcmaL8WmsF?7A@RTTCBLc6?b=ccXxso4H~R1?gT4RtT+@6?yiLril%4@T7niU{_*z6 z{eIkY^CMY%XUs9jnrrU0pClu(+L}t3=w#^6o;|}(O%cy#x4LjZZH1q*$X;nePbVE4Ruj~ha0EO zKNwDso99#XvuEN`AWs{Bi@gtxt-YhOy9C{FXD=O%vz-K;k$?ubhNqmple2Q5m%Uz~ zramCh1t4NaCnZTE4ibGLaI^QZp#izMx_gU)Bn$}9dm*VB;%os*A`rzjVfzrR1HKOd)umm?RCh=|BP9K5_7PY4e00Cyi75Qn=r z{eKwb?Y#kB&YnKb9_}>%FxuF9`1(lDJt_Uy6x=-jOY83a?=n3Vj0LBly^W8Dm%fLG z>wl`K?d0L(;qBz%Nh7BxK%-#;aCZOa_%B{VLsZ4x+sDQoV6P%CLHESK>FjJL%Eu=o zC@9Y_#G@c6$it(+FQO9uXOy|HR6B0DRr--F^NOYxjR*h5u*lKds>A z`IK4S-pkp~-cHfW!;R+eltrEYw-$l_$@lMAyZ^04@PEc~J&ED^XJP+;3;mx{Pu=s+ z@V{;QbnxHCw|9T)cCV+l_Rhg0diIRBPeoovAGCCkhmu7!e=!0j%CIc1U{;0rzhnzj zRH%Ot=y$J%$R~ap!UOQPkR*PGC6W<##xjgp8{rXFTPGUhD7@5RKexzmd%We{#b|6i z`?lh2^&{jx)SK#0PhPgi&eUZ0vBcGiH`@-FoRy{i3j{L(leZ-WVvvA2{XVGbnr9s* zG$JW*Sqd>q(BQkwNG{TIu68tN%oQnb6^FFNR~xPl$I zm|>W*j{xhT(g3sl-2z1KY@&qA0a~--8mlbo6MSY3Sy29DZRC=_#b9K&IcW(xbn3qD zali;DIL*NQ2a>E?#=CXQMk;2IJDpfLGR5_w?UEM;`!OQP>sJa904@JRBdgqw<{A-f zPODilVldJY3tG8mjj<9Cq%HNX;km>BP=EQ!_>VT)lC6`dm~$b&B*aCJ*_t6bQD*XIIA zrrq#>z~6ik=?Q&P-|3PvgPI@=_MRFRi5f&qlac?_B_cT$A11<`f;&+p^s(QUcKGMS zNYwS6+Y109HVx5PCw$%fR|2X^WJR_R&T>NOOaXhEOOBl@ACRbf{Q38g%!l_W!fCv{ zyn=GMr7&FEFtoISlT(_%iFGOyAW*%LTFx{?IMb~HaOTxco0(xXa`wb0B-{sjpkZ9F zbnZMIZIc!;=Qqv2^WY_d{p1IDf88Rxts3(SLO{5`#Xi5aUOr5);GFV06(V2G0%QE` zw{cbL@W!uuqA3n1q)>mMxU?wl*Pwndp(E*^iJ@$Hm4EfeJ`y=_@(E_@&+FH@D;5#% z%5izR;P_>FEfS3Nmq*3SI-GpsAP~&&m$citnCRwyK%Fs4!m6qG(fj((-y-2~&7)oQ z4#JKn4nA=SUWP)V&DUvjP#Hz?-yUdXY;@ zNlmhBn0p;i0j^5OqhqN%)6E;;VN5UVdzE$GmIS%ZKVBDViH>uKNOQ&Uq5yG0Dlp-V zTpnO8cV6#UAk z)?vp{kNcLNu9V6yaw#|j*h9p`zNZJMyYcx_9Zx@es61Md4Nc*y09>UV7@wE@EGya!%G<~=$Cg%(LWWrD<&NXYR$#UpU; zl-N8X3auH&u_czz`2@`)@9^Q(Z%i7Hf=u*EDPZM>R2Fk4J#Q=0-x+Y2G~abPx7&Ra z2NL1RzJ6GzOMmMRqU6 z$VT^YqYCg33>3Q}C1=wdL-qO~RY!>-RljOAeEMmD^wu(R)f~VT!$Ug{0mvR$s&%fPY=gWk9kNN8m)<5-VE?(DW&De z_K7#3AU;h7d9k4~t}aji!~JOUAShjMOMAIETdSX?IMsgoD0hRthVvFz_Pv zdB+jF*ZW#({d2~{sX9F*h~py)k>5uVOoN%aFYVn4R`h41lz|0c2VZIB=nppL5y=g> zu!5%WhCXBkP}Z@2N_Vz!AzjR@qHsS0JYuj-#`U;&ZpDXpK_mAhyos?3Q{PNOL0pmg zC+VYZt}AEuYBcotKWk`m>a(=zjXxDB3#5Um zVOPP7@tHWfoJhBge!5gA4xHSVT7cu2&GC^pQ`A)wCChhgTf&%uxo`T!dK!h-3`){W zpvJr6%XD*gpM-&tSGPXMc(X9$3n{M4OiY7A9Xmh?(uP=TgDFkP-egM4nbFfm?^>b$ zOW3Npm^VN^_io|YL=pYnX73Ft-K|c|A1*#YT?(+WskD4SwQN8cBq))xT(;M{@0~D8 zL`ANR>lb0mKLRtNENx&SAp>P7857a%ZP{0S3snYW+tbd!X-*{GL}**b@G};C z)Q3bSoD}bG=Jx$POx1UDzM= z`-IZDl+GJgv`ehIT0``{&WDsH3nEG03F1%AU(!=nGsjuyzcneB{{lp{>#5)ndCUO;OINf(7fpu|jyopb#q zlcAO8B?*00y0gq?{w~Rm#QuV^oj)tPcv!7-@bCr?Zk?hlTDK)}c8r_PG$e2Sxtqkw znT9qczCHX17&fsDl3Vm2V-Aarj3y0gN1oyt+l*_2>We#0j5b%9+SO=cHnf?jhBVL* zc#p)VMKXMa?+hxBt}v^^v`27e&jC%v7U zYKYuMhjG$Ix{NA9pgZ+vM>wy}WFw4vHwJAgeD0=m%D2|9gU5(o73(HHxx~ z$`tS4W>`?peBKOuh2OZWrn>N15K@lt?#^(;0WnTZ?_LtcuN$kZ4>wSZ(5iUWZ$`jTC z_ci7nCc@Rp`ZOBltEe^pK#3|uV{VnV_K305Q3%H-7{5pCjN#f=F$6GY0!$*`&2k!S zIddNLT9i~PSY$C(Vk}fNjSg5anR_qHRGpDH-%`M=-M#Uy)$8I8o`groI|!?V_x3%D z*jIq7JKZ%3t7W0A9=PatJ(#|9PuiW+t}h-&qnBZ5P*GhxNr~gqcYtmMghEcf1;N$b z?-KJjMQTx=;qx4;2QzXIHdtmV{?c(qZn=JMuV7*~^o}L0PZRG-cNY-v$m+tCNWA;qfeK|Ja$ z?dtZ+=kKMyDZQ?#yBJCu@vCPRGRG#W=#Uqy7gWdT#9=CV-aUP``ekX{im2fj$(ICH zrqyj>sx@=@VhTUP^u8#smC#HX@iA!B1&~*#t~u+7Nq74FS*V0Q0?u(R5}(HKHeXU| zaX6UE!_YCc0<@~U?km)OK|HeGDJuLE1en`EE(|f3b_8Kc>^KoR$h}C4y*efcDc79k z)u3b4(j8swz`YC~>rtU}6ui^r7(E_B<4DBV|5_E&6Rp|K-w*sw)y8zPZhwG05z^^w zLRAg*Our%j74=A`>3&;5GjxWvxa*y0L3)y#_vIKsT*HJxThAl=kcG%Qs?J-inZbh@ zq`FJ)@rN?G3!zzcyL6$GtD~<-+L`H#r!{AWlr~}E%2bRDzO|+VWq4@vyEP<&_QmKI7yfHm7c|~ zkdcGa5KJs;WE|^Wm#k^lqqyS>>?&VZTzP8uAppMl3)U|MmG^Sp-h8%HE>eK^IF3|u z6blQxe|+599-P{(w9u$@#Po)>v4I0!Sh_Zp$De)M6#l5 zMLd&@Q!>%r&X>3(dy1Sy?PO++U1`I)&{?M@Uo z%#2bAa3&rk<63k``;b?*UQ=TG&ME|}*pK;D6(8EIW`d64<`Ai~rNBrJ{k%38h0VrZ z)(*?!ceIz6p#l3bgLvo%tKy^07Gr2rg@|ENO0eGhf^tf4;XC)3w)a9%k-CFMjbN)`@oRUehd@f#YrH`!qtJ(}CQ8lR z+MUwQHG!ZjF=2+LRco1w;NA)|e&(F=;@5@~YvQ*}WwH|1 zW{l!fpO$_sGYm*FDc`WXx|&tI;x;P(o+0HlocYS>GuQ0YJ}uF5G$wr!TF%IET{Q4|>d}!k>Q%%+Z{vc^)k{}BmP<=f)KU-84}F(W3?QXO?M&M_+fH%H zP1RGVhy8_TH3xc5er1$IF9!{db){AF1?8D6r6x6UC#X=y=*ObiCe zZ|cKVcuN6?)kxDj?`&dz$0gLFecX{V&Au;2g)e>UH(kt49)MhGU9UX2($=TV6dnKe zCR!eldvubP@OGmDCuf$w`Jo*ml6I!*Z&(Oa{eaWP`8m*aE|7#?ovVrug{PNqINSdu z@u72)Vd`WJ6OYNAB#+hOE$k8B(PtN)wdfZ;ELi6(7IlI>Ir~TU<;xx4Tn0^Lm885k z!2|CbsSv##hl_!eoJ#>wpS`2KtE(5CZ!Hf~l*~7UMiIR+&UO9*juK5%YYJjtkERgP zggP=dxb4%E8W((`2g)%g?g>E+RZW)7*L)HMnl}Lnu;J?<6ODpm3RLPGq6Vl;z|aNp z5*5uzK$K)Bp{dY?A*8crtu--(0(l+bO&*>5!u!KQD+;nt(a~g^`=2T;v-g>ul$x_u zLcQ{AV+YeSFP`@OYqz>QCGH1>^M==xc=@-W?jSBT@vfSWgAluU7WT?eutjJ2$9ZSdl;^rlm2JPtQ%6@Y$l7(6B9 zlqVdq@F&qdugX5%1MkA<3y`rQM$#0zn1``Jaacc^tu(EL=wALU?vJ70Xwx&+^%@ab z;OsbwDLNe;#0Iv-_)%@b(BG3aEi4P?nhDFaEm@06YtqSK88&-%%KNKLjXM)jlt$0d z(q8vr_pCL!w|MrQ((|ceeWT@-V(H#9J;(%sS2B8f8}xNox|N@GD5loR?9+n2fWKZY zc(Y*>gX85*ALqgajeA^)lhbXRioH>St-U3|TRjZd87wh*%kX(J1H3jQhhtV+p3fcPQ>XQUKsF9mm zoH!0Sr&YY;%y1%&bJqhNV_vk;?sx~5__YLXe|G`Bd!GququTI(0J-~}A@a(HCwYmO zWj>cDZ4_FKb}1f&lN4TD2*1zVVhK*wFN*D6oRC-~%)GsE{(N>owOd z%1cRV&^^^z@YP_}sI0j+rz_3|Zk9B;z|^}WEhV^Bpm;=Uf9IpY5Fn6A|FO@j7Z8&B z96ZFHGbnNB^C(Vfa20auH(3;B>~V!Yon}t?kpi_J#_}@sKCrK4uY_Xf`p7hv`XQ=8 zWNp{9H3nF%DY43p1+@_OnTmXtj z%WgVqwJ!5UnSrBy?rhLiXKT?d}y73{iOJdN@mhf#J?H_awxEp#WUbKF{0}s=woC6Y47);j* z8rB1{w*AVT>0NSmFtEae;*67g8T_nxO0c+ov@>{eu5n{@#RGTr>^Bb8=wBEbB;0`7 zz|!xSHUh-AuPL^G!?~=j#GR%GzgKr%icju#i74clZV*{+CP!VXw1lVu78LdOSdw{V z{4*;Lt7ier$fJSEz6+QygOA+}x_4ilo(2pO&gO2#M3YigPU!~HbZzFpPP(m(7_Dq( z6E$iYyBlF8m8$F1Cuz4}csC&yn=cM8WVgfaL&h75{Shd3)~!cR zCrAVcxl!YrKl=V^piF14E39&aLJVb9-eT+g2xImTQ%l7;}SHq_(LSbo^EM-HXXtZ0O zdW3nm2Xc86CsIwEsbP>@Q~2ojkx)cvw^BKDjB5;4cJZr2KyPiMdSz9LK~+wi4%NKr zbN2DsiY=l;nH8!iP250F?V2V~z(9!|pVCyX9mL_@_ zlcc-NP!BZ_1zEf>pRi=1_Kqh(3X+M9b?No%R8SQvDbofi&Fz$Vs(U!_CusVn+==X` z4cUNCy9%^!gq7dHZ(d7yf82(&o(5y7mF`*OIvT28jRocQywzcRqsbN4HuB~hLSmiP z1-e(k^;S23LfRT&ykT>g@~+hOx!lg!Sf~$2v?1w2ja>QgaJtM|?p@SM9&ls$0J<8;>A`IHQY5INUj<+t`aZ}v)4 zTMv2I_QwzEM=Wg(QohmrlBbJ|jcKc6rM(eJ>_{Ce7!j7Wl-87@z;z5`*K8^*wY?^P zXZWbVI~{|7l7A`bsQ034<(8h(+iSK&8}ijuX4p=^0dk;0zaKuYr~S&idu-;u+p3y# zh&LfPIM%YArf&^E-XlY^y8hl$%bp>Gi+MuNLb0pOLODZ47f-(U&F8UH%lFk)H3Pg8 zGX$RR8odn{YWkC>IU_o}?Bgs(hY9Wy8?sIR0}Vgrg%#6#9%R$r^539t@SnujcyONj zpE?(`U`-_m!Nt>6WU8?;PR;ou0f`wuvuj1xX4j}4+M{ZmBHI>~O54)>S3Z}=gNpD= z-B$ESnoSp)Ib~)v6o{j~ZKMpo4IJYIwwCY%v9+$k%2a=ut+ETf&f;R4JYriH_yjfh zcF16FMV7{Bm~xVwCmSeQ>{H^VpmBwKi?xX5tMS?s%PV;WKlk>RF2_ zaQ#KT_9dmokkCTOdHzpHF5DT*Q$Z=`2&Z8*iEw|IL>%}ep?*ArUV@HuU70}fr}vsu z7ct2;mYIn^8+D@M!HHQVZamDm4kufo_&Lv2PQ+;2qON&of3i4Z`6^WdW!GxVHw*o( z9RCu?86CO{>RZqmkKJi#IZw5A|C&P3R7~+e1O|KX>AO!{L~~2Q^j{VcJ?fn1_JtHu zo#68?Z;9QhCQ%>Wl+v*xbCBkOYksQ3ErxKmI#@o+=yEv*{noTagX`J);d!Sqs6~1- z_t3kU4AG&!bh}$vq8bSpCgNXZ%R$m zvOkBz6;t?`*dmP4KpQa6S(Tb1v2UM_yTrv=nIeEr4bEdkEf&tcKxgqz=0#_b6#}=d z<1+YBT8K_dgbVSiDuNBJv!Zzw;~H`1CnOI;NRH;M5O3aN0V4|fV%s{@tfO&#!{~vE zXkC?8J?SKAwT&lDA&ld*Yz*V@55gw}#xX07=)to%1He+@{4HiU*{$`=4_`dDSl!dE zrb@kaTRT7dc#5TRzxH}})^%cZIN6|2;?tLujjh6Ku4c*Pw+2LJ{e43$piypJ3@{zz z{ZyQ_eCg6H#lsA4@F@ubKQ?$Sr!)(1u-g0Y@!Y3D0$d`L8{h{xE*7}P)$8&a||XD*TfFRvL{%LTfbnlB1i z`xZ=4^3YZ0(&j19vpsX0>pdpp@?^hP1Lua|`g^OU4F@JZvt-JBeIhxTzTB`_7Ha(C zXpMKEgjelG#+Z1pH3QN?T{LaXLXs&7drY%!CjC6=jey#;hs!{-|i#z2tEed4Ti=&S3x@^6XZrGR|k} znjEuABs|D(T|wc}%1sHwoY(yB{a6Ys6`5RKt#YYI&kJ0bNGe4P*Uq9}0YZR`s>=o) z$^kQp3e)J59I>B@@PGAi_X6G%Sved~($wM_il`m%ViYFIyuN(JJ|msKAXrNRV#341 z1|2JQNES0Z;*5kT&$YHc%^PE`bnRw~uILz)Jn z)rtYuuV1r^>4a@XS-a!^ETgu|Hbj0rKjU`uCKq2mWUW!kEocyb*qm8%j`6#5FX;H5 zH}?G7Z?<6e>UQ1ZW!lOfGLsiJ6Cmv5nnJCrOjaP?lKh2^41eXWTy*hxjZKwSr_VJ}-~$&#D3 zzhiEKdrOMKKU0O4xvH7-t>i*p@I!2=k5-G?6tO+uraKwk8#JkfX*#Z{*%i}i_x~lXo^+A!ibrcM>WX|z89iEn| zyC2#BpijrGcW&p}+^3j>Wt$A*=Jrvh8ETLM8aKVsi0&;hlS@-###$Xy))F)OMv57; zZdh4t?c_)zrcUIaOVOUk1$;wMCE>D~-O=N0NFI9^e^C}x37OgGLo)!Q zl=io=P5JDB<$lI%4Y+J3XEphD`qO&Kd_8!yc<*ECCAvC#XTpXe+6u_cmTjEJ| znoqk>=_ZZ4uO5-(m)F08ceF!p<}!?TgW`7279=mKmj~~5tj;zg?PgUz-)5VMM%0j%)T?pU<0Uk|D3p5{2e??#5jMB{Y!BJEFH zuWNq7jM!7<2zWCvPQRj%cXAC#;y_}2ul?h8L$gjQfeIy;;;WXDudit7Uv|Z2b;SrX zfetgr<80WRG+xgFc;C!8+A#ako200^e2Q~AmM2ENwvrd`El^q3CVWk8#pR}l6cCg~ zUYS?4ylI87x!WdHAgi(~ry661S05Qi1wbZZh3H*x{Rw|u!|$*brVLWole{Fe)at#5 z&|6f+nmc3oc&?6vkxR;joiAOb9VuypZ0J$RUBbNxlH~&My}W2{rLRnL z_-^!!5*@@mLvLnIN0QiIhGHHqzPd<3m6&`Vvw8X{6CQBzCaG00F|!`5<-vmAC>~F}0=9+5g-X4W2>mQBUE2eh0%g|SqINm6Te;DOFibuJZ*{m1m-=$li zA>OF0B&aPG^YmL#sfV^T*RCPN%5N9BL>0$sDyvtimKQ1W9gBJ=5(@^odQd1zJ)8Lo(zG zeg;Iwc}daKZlFmS1a-tPNNEfJ99rixy+0qS+Sm5iq zL+jh*2DCx)TBOktKeP!XXqS-sX*+N5l;5o1VpaD@M%Pak^Vqbsa_Eo0WNcXh8i zafO?AZFRj;yl(n{r6|&IBA_<(2I?rB(2@jt?Fv>m#>YoLznm1vhc1`weTd-;OKNlU z7eAu`QWzX1>w@I0VgfW#HL`x)yyghsLOaU(#V{i%@fmXs*QfgI)M>KgCz&&%`=PNZ zPu+yGi`h*t8-5KMsj5_yxl+d&O}k-3yJGaH4TJX)ynmlzXsKl%oOgmmFTRO-s`ckV z&u!9meAquxYhwk+gHo^`Q|*lIBH2K=|B*NDyfTf|*+wzNwSNZ2hkhakih?%7j(lPT zD;YT{1@b6F_gc~lu)m$%A9Eb*aK&Q@qrFOd-)-p{v7hkz2lg2jw=-pNt0yOAU(svi zLYL#99x*+EkqXq&U$tR)E{^73j>i*upyP+bN9CfUhi~MgD<%5{I+<#AWsg?a)U-af z&|(T&_pI1K{XL`TB94{Ou)PPi5Y+MbOb^}#nvWufpZWaDcRLGjsu}h_miC|C;Ors| z=3G3ILzSiI!nCg+;$03@KDrVVI`VxANUQz+09hW z{~WkYa@aKYcKD$MeY0x*7Sec0vr5BAj`1Ov&~s(J`O2>w{g%{Jq-lIT_L=68?J+E* zGGTu~fpOk97y&7_Diw3aL;G8#ku@_Hyb)LWa$+&s zEF~rPhKO&PraSlge{A(pz0+TTl9mN_uDi-)@vS9E8zK$1amRo!FM&6Ys)yQdvVSt? zd&vc0p2sNLeK7sJ7^QO9Xkp(Tm$9A!ml{~8K2#1711%(JGl8Eh9QYUDKEx@cv!JHg)>??HhpzbPA3DM&~U< ze~Rf!mHiBTPgT>F;L?v|Ymp&(l9!ZA&Mt9(uv}|zk8-{XfKyu7vYP#;ao1qBoecXG zs7P|7#x6hY;x|`wfR2^)K5ub~0ncUzK+Ybe)UnPC7iajN`lE-k73KK}UD zKzHTYGesC!j*8N598|aVJHKu;Qd&wK$pOh<2p%XS*W6`g#nH`{4mC<`Tm8tWUzn}AWi3+;%dy%2o{JaR5Qy)!>H z%gz0!Cx`4fqYzD`j6j=|L6X8+kHP1A*E0lNx2(ItObT73J3_eKE@=MB4=jMRRrw62 zG<8C+vWR^_5OLT~3Brb~kl1OQ5_pGlWb@Ulbtbkbg~d5y_X_mvTrZdJ`R2u?sF<7U zZv~d(&CJ-A72TvW_u`}1Z=|JAbP7kMUj`&-f$L>F7R;6ggDkC*jsf|P&oalP8U8fK zT_2wdY0JFNakO#`swMjx zM!cT4Z}M9M_60r_9>16xcaX^`A9gqPZ`l_3nb%}8T`Chs482ZkvJhPcGX?jMR}=ah zTZDVQSSASC6SiqO@{GT!Qk?JszB*o9FY#TP6Dko7-f4$6V16IQQ`bDNN^kJC2IR;t zY?SB&z67>8I0W=}iwTS;u3x6J_59+L8+<7^p24|fLiU+*HlGuF3@?Ppk+A-3MnmFl z)qZ;$wA_$w?+0srI|;Kh_%r5`bfl_d$kA>k$+avzku2rs<@<_TvP^;(tTuzj zhE_CzlafJ^=I2x-PY=Nl5R<=t%`qL1pvH4;}21B9;( zkl_bYZ2+YII)|5v`(DLhC^8SK&@Rg;W2>Er#Wa&~W~5#GeHRr{N`OC4&x8mdeH^(Z zSo~{uE-6NJ{V*qLT*hB@@O-Qm!r>wH*J1pN8Ht>Ri`CHLtL;2>NxDqFb41bk*1z+J zhV>B-vfA2MMCt)_#) z3G~quaUUm>*(ov1gX?+|@8-u$!zgCPz9kxLJH$2OO{(l${;)=ie$@*MH+Dtp83U5!%o~k zPQ8KRJ141&WM*HM=`hd+PDS93YX&}Sllg@j-BHpM?!v8!WeV^^4DX@GQ`sea*>H?=b|NHgB}D2V9jt) zJ=prm-}$6M+ZsPel4vwOBmuhqij3Ujz<~(=Z+%`0#*Vm+M8&7Up%ajiBU{{m!_%D9 z1zJjlE#0`HNju{ds8|+m7h{Hj5#iNXfrHNd}8lmEE zQSW{7z*8sq+W$*S6LniEU?Z!#B?GdWkjUeg4$&N$;$N7gqx*-E<^6-zhv(0nSsJz2 UWxWXg`G1#+f~I_}taaG`2PLnS&Hw-a literal 0 HcmV?d00001 diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/app_web/controllers/page_controller_test.exs b/test/app_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..9a7a758 --- /dev/null +++ b/test/app_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule AppWeb.PageControllerTest do + use AppWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, "/") + assert html_response(conn, 200) + end +end diff --git a/test/app_web/views/error_view_test.exs b/test/app_web/views/error_view_test.exs new file mode 100644 index 0000000..85a326c --- /dev/null +++ b/test/app_web/views/error_view_test.exs @@ -0,0 +1,14 @@ +defmodule AppWeb.ErrorViewTest do + use AppWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.html" do + assert render_to_string(AppWeb.ErrorView, "404.html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(AppWeb.ErrorView, "500.html", []) == "Internal Server Error" + end +end diff --git a/test/app_web/views/layout_view_test.exs b/test/app_web/views/layout_view_test.exs new file mode 100644 index 0000000..1ba98bc --- /dev/null +++ b/test/app_web/views/layout_view_test.exs @@ -0,0 +1,8 @@ +defmodule AppWeb.LayoutViewTest do + use AppWeb.ConnCase, async: true + + # When testing helpers, you may want to import Phoenix.HTML and + # use functions such as safe_to_string() to convert the helper + # result into an HTML string. + # import Phoenix.HTML +end diff --git a/test/app_web/views/page_view_test.exs b/test/app_web/views/page_view_test.exs new file mode 100644 index 0000000..75067ce --- /dev/null +++ b/test/app_web/views/page_view_test.exs @@ -0,0 +1,3 @@ +defmodule AppWeb.PageViewTest do + use AppWeb.ConnCase, async: true +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..ec9556c --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule AppWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use AppWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import AppWeb.ConnCase + + alias AppWeb.Router.Helpers, as: Routes + + # The default endpoint for testing + @endpoint AppWeb.Endpoint + end + end + + setup tags do + App.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..4a83f5a --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule App.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use App.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias App.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import App.DataCase + end + end + + setup tags do + App.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(App.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..4fe7a40 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(App.Repo, :manual) From 15114b5d8aa220b521fb9e7c5e3acf87bde4ffb5 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Fri, 21 Oct 2022 15:09:51 +0100 Subject: [PATCH 03/33] remember to run mix assets.deploy to create the required JS & CSS files see: https://github.com/dwyl/learn-alpine.js/pull/5#issuecomment-1287020097 --- lib/app_web/templates/page/index.html.heex | 2 +- mix.exs | 3 +- playground.html | 118 ++++++++++++++++++ ...vicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico | Bin 0 -> 1258 bytes ...oenix-5bd99a0d17dd41bc9d9bf6840abcc089.png | Bin 0 -> 13900 bytes ...obots-9e2c81b0855bbff2baa8371bc4a78186.txt | 5 + ...ts-9e2c81b0855bbff2baa8371bc4a78186.txt.gz | Bin 0 -> 164 bytes priv/static/robots.txt.gz | Bin 0 -> 164 bytes 8 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 playground.html create mode 100644 priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico create mode 100644 priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png create mode 100644 priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt create mode 100644 priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz create mode 100644 priv/static/robots.txt.gz diff --git a/lib/app_web/templates/page/index.html.heex b/lib/app_web/templates/page/index.html.heex index bec4e92..8afc357 100644 --- a/lib/app_web/templates/page/index.html.heex +++ b/lib/app_web/templates/page/index.html.heex @@ -1,3 +1,3 @@ -

+

Hello TailWorld!

diff --git a/mix.exs b/mix.exs index 4757f4b..ce82949 100644 --- a/mix.exs +++ b/mix.exs @@ -62,7 +62,8 @@ defmodule App.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], - "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"], + s: ["phx.server"] ] end end diff --git a/playground.html b/playground.html new file mode 100644 index 0000000..9c9b8b0 --- /dev/null +++ b/playground.html @@ -0,0 +1,118 @@ + + + + + + Alpine Stopwatch + + + + + + + + +
+ 1657807647000 + + + +
+ + +
+ + + + + + + +
+
+ + Hello 👋 + + + + + diff --git a/priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico b/priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico new file mode 100644 index 0000000000000000000000000000000000000000..73de524aaadcf60fbe9d32881db0aa86b58b5cb9 GIT binary patch literal 1258 zcmbtUO>fgM7{=qN=;Mz_82;lvPEdVaxv-<-&=sZLwab?3I zBP>U*&(Hv<5n@9ZQ$vhg#|u$Zmtq8BV;+W*7(?jOx-{r?#TE&$Sdq77MbdJjD5`-q zMm_z(jLv3t>5NhzK{%aG(Yudfpjd3AFdKe2U7&zdepTe>^s(@!&0X8TJ`h+-I?84Ml# literal 0 HcmV?d00001 diff --git a/priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png b/priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png new file mode 100644 index 0000000000000000000000000000000000000000..9c81075f63d2151e6f40e9aa66f665749a87cc6a GIT binary patch literal 13900 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 UWxWXg`G1#+f~I_}taaG`2PLnS&Hw-a literal 0 HcmV?d00001 diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..a1d6ca87acf965690c360a6c298762df132975b8 GIT binary patch literal 164 zcmV;V09*ebiwFP!000006GhB14#F@D1<<{x_)<3{nmsc&01l8+w~3U*RqQGpA5#V- zFJJ!ujkpsbs_x>Q>%C8nXI9a-PTV&4Pf<(8$_)#@jzU#~Ca$oH+@Xv^2pS2$$z&U> zDbp|xBOZ)7RD_%%ds?Uo*2d-R8Q>%C8nXI9a-PTV&4Pf<(8$_)#@jzU#~Ca$oH+@Xv^2pS2$$z&U> zDbp|xBOZ)7RD_%%ds?Uo*2d-R8 Date: Sat, 22 Oct 2022 13:49:46 +0100 Subject: [PATCH 04/33] Downgrade Tailwind to use 3.1.8 version - hotreload doesn't seem to work with 3.2 so using 3.1.8 --- config/config.exs | 2 +- lib/app_web/templates/page/index.html.heex | 2 +- ...bots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz | Bin 164 -> 164 bytes priv/static/robots.txt.gz | Bin 164 -> 164 bytes 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 4fa1e2b..e5c0241 100644 --- a/config/config.exs +++ b/config/config.exs @@ -36,7 +36,7 @@ config :logger, :console, config :phoenix, :json_library, Jason config :tailwind, - version: "3.2.0", + version: "3.1.8", default: [ args: ~w( --config=tailwind.config.js diff --git a/lib/app_web/templates/page/index.html.heex b/lib/app_web/templates/page/index.html.heex index 8afc357..2075d90 100644 --- a/lib/app_web/templates/page/index.html.heex +++ b/lib/app_web/templates/page/index.html.heex @@ -1,3 +1,3 @@ -

+

Hello TailWorld!

diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz index a1d6ca87acf965690c360a6c298762df132975b8..043be337a41f4dd7232202988a782fd515bf5af3 100644 GIT binary patch delta 18 WcmZ3&xP*~QzMF#q445Z!%>n=+Vgo+_ delta 18 WcmZ3&xP*~QzMF#q41_0g%>n=+q61F= diff --git a/priv/static/robots.txt.gz b/priv/static/robots.txt.gz index a1d6ca87acf965690c360a6c298762df132975b8..043be337a41f4dd7232202988a782fd515bf5af3 100644 GIT binary patch delta 18 WcmZ3&xP*~QzMF#q445Z!%>n=+Vgo+_ delta 18 WcmZ3&xP*~QzMF#q41_0g%>n=+q61F= From dd85ede37d2cdcd76aa496bbe39c1c48c5b4929a Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sat, 22 Oct 2022 13:57:50 +0100 Subject: [PATCH 05/33] Add Alpine.js Add alpine.js cdn in root file Create button using Alpine to test everything still ok --- lib/app_web/templates/layout/root.html.heex | 2 ++ lib/app_web/templates/page/index.html.heex | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/app_web/templates/layout/root.html.heex b/lib/app_web/templates/layout/root.html.heex index 072c371..2bdaf05 100644 --- a/lib/app_web/templates/layout/root.html.heex +++ b/lib/app_web/templates/layout/root.html.heex @@ -14,6 +14,8 @@ src={Routes.static_path(@conn, "/assets/app.js")} > +
diff --git a/lib/app_web/templates/page/index.html.heex b/lib/app_web/templates/page/index.html.heex index 2075d90..737a5f5 100644 --- a/lib/app_web/templates/page/index.html.heex +++ b/lib/app_web/templates/page/index.html.heex @@ -1,3 +1,9 @@ -

+

Hello TailWorld!

+ From 17f712e08c20cc6f92c2f99d637d13ad011a8eb8 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sat, 22 Oct 2022 14:25:23 +0100 Subject: [PATCH 06/33] Use phx.gen.live to create items Create item schema using phx.gen.live --- lib/app/tasks.ex | 104 +++++++++++++++++ lib/app/tasks/item.ex | 18 +++ lib/app_web.ex | 2 + lib/app_web/live/item_live/form_component.ex | 55 +++++++++ .../live/item_live/form_component.html.heex | 24 ++++ lib/app_web/live/item_live/index.ex | 46 ++++++++ lib/app_web/live/item_live/index.html.heex | 41 +++++++ lib/app_web/live/item_live/show.ex | 21 ++++ lib/app_web/live/item_live/show.html.heex | 31 +++++ lib/app_web/live/live_helpers.ex | 61 ++++++++++ lib/app_web/router.ex | 7 ++ .../20221022131733_create_items.exs | 12 ++ test/app/tasks_test.exs | 61 ++++++++++ test/app_web/live/item_live_test.exs | 110 ++++++++++++++++++ test/support/fixtures/tasks_fixtures.ex | 21 ++++ 15 files changed, 614 insertions(+) create mode 100644 lib/app/tasks.ex create mode 100644 lib/app/tasks/item.ex create mode 100644 lib/app_web/live/item_live/form_component.ex create mode 100644 lib/app_web/live/item_live/form_component.html.heex create mode 100644 lib/app_web/live/item_live/index.ex create mode 100644 lib/app_web/live/item_live/index.html.heex create mode 100644 lib/app_web/live/item_live/show.ex create mode 100644 lib/app_web/live/item_live/show.html.heex create mode 100644 lib/app_web/live/live_helpers.ex create mode 100644 priv/repo/migrations/20221022131733_create_items.exs create mode 100644 test/app/tasks_test.exs create mode 100644 test/app_web/live/item_live_test.exs create mode 100644 test/support/fixtures/tasks_fixtures.ex diff --git a/lib/app/tasks.ex b/lib/app/tasks.ex new file mode 100644 index 0000000..3e82b05 --- /dev/null +++ b/lib/app/tasks.ex @@ -0,0 +1,104 @@ +defmodule App.Tasks do + @moduledoc """ + The Tasks context. + """ + + import Ecto.Query, warn: false + alias App.Repo + + alias App.Tasks.Item + + @doc """ + Returns the list of items. + + ## Examples + + iex> list_items() + [%Item{}, ...] + + """ + def list_items do + Repo.all(Item) + end + + @doc """ + Gets a single item. + + Raises `Ecto.NoResultsError` if the Item does not exist. + + ## Examples + + iex> get_item!(123) + %Item{} + + iex> get_item!(456) + ** (Ecto.NoResultsError) + + """ + def get_item!(id), do: Repo.get!(Item, id) + + @doc """ + Creates a item. + + ## Examples + + iex> create_item(%{field: value}) + {:ok, %Item{}} + + iex> create_item(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_item(attrs \\ %{}) do + %Item{} + |> Item.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a item. + + ## Examples + + iex> update_item(item, %{field: new_value}) + {:ok, %Item{}} + + iex> update_item(item, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_item(%Item{} = item, attrs) do + item + |> Item.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a item. + + ## Examples + + iex> delete_item(item) + {:ok, %Item{}} + + iex> delete_item(item) + {:error, %Ecto.Changeset{}} + + """ + def delete_item(%Item{} = item) do + Repo.delete(item) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking item changes. + + ## Examples + + iex> change_item(item) + %Ecto.Changeset{data: %Item{}} + + """ + def change_item(%Item{} = item, attrs \\ %{}) do + Item.changeset(item, attrs) + end +end diff --git a/lib/app/tasks/item.ex b/lib/app/tasks/item.ex new file mode 100644 index 0000000..dadd1b2 --- /dev/null +++ b/lib/app/tasks/item.ex @@ -0,0 +1,18 @@ +defmodule App.Tasks.Item do + use Ecto.Schema + import Ecto.Changeset + + schema "items" do + field :index, :integer + field :text, :string + + timestamps() + end + + @doc false + def changeset(item, attrs) do + item + |> cast(attrs, [:text, :index]) + |> validate_required([:text, :index]) + end +end diff --git a/lib/app_web.ex b/lib/app_web.ex index a25a25b..e801cec 100644 --- a/lib/app_web.ex +++ b/lib/app_web.ex @@ -90,9 +90,11 @@ defmodule AppWeb do # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) import Phoenix.LiveView.Helpers + import AppWeb.LiveHelpers # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View + import Phoenix.Component import AppWeb.ErrorHelpers alias AppWeb.Router.Helpers, as: Routes diff --git a/lib/app_web/live/item_live/form_component.ex b/lib/app_web/live/item_live/form_component.ex new file mode 100644 index 0000000..379f721 --- /dev/null +++ b/lib/app_web/live/item_live/form_component.ex @@ -0,0 +1,55 @@ +defmodule AppWeb.ItemLive.FormComponent do + use AppWeb, :live_component + + alias App.Tasks + + @impl true + def update(%{item: item} = assigns, socket) do + changeset = Tasks.change_item(item) + + {:ok, + socket + |> assign(assigns) + |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("validate", %{"item" => item_params}, socket) do + changeset = + socket.assigns.item + |> Tasks.change_item(item_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :changeset, changeset)} + end + + def handle_event("save", %{"item" => item_params}, socket) do + save_item(socket, socket.assigns.action, item_params) + end + + defp save_item(socket, :edit, item_params) do + case Tasks.update_item(socket.assigns.item, item_params) do + {:ok, _item} -> + {:noreply, + socket + |> put_flash(:info, "Item updated successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_item(socket, :new, item_params) do + case Tasks.create_item(item_params) do + {:ok, _item} -> + {:noreply, + socket + |> put_flash(:info, "Item created successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end +end diff --git a/lib/app_web/live/item_live/form_component.html.heex b/lib/app_web/live/item_live/form_component.html.heex new file mode 100644 index 0000000..0d0ab52 --- /dev/null +++ b/lib/app_web/live/item_live/form_component.html.heex @@ -0,0 +1,24 @@ +
+

<%= @title %>

+ + <.form + let={f} + for={@changeset} + id="item-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save"> + + <%= label f, :text %> + <%= text_input f, :text %> + <%= error_tag f, :text %> + + <%= label f, :index %> + <%= number_input f, :index %> + <%= error_tag f, :index %> + +
+ <%= submit "Save", phx_disable_with: "Saving..." %> +
+ +
diff --git a/lib/app_web/live/item_live/index.ex b/lib/app_web/live/item_live/index.ex new file mode 100644 index 0000000..5456176 --- /dev/null +++ b/lib/app_web/live/item_live/index.ex @@ -0,0 +1,46 @@ +defmodule AppWeb.ItemLive.Index do + use AppWeb, :live_view + + alias App.Tasks + alias App.Tasks.Item + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :items, list_items())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Item") + |> assign(:item, Tasks.get_item!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Item") + |> assign(:item, %Item{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Items") + |> assign(:item, nil) + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + item = Tasks.get_item!(id) + {:ok, _} = Tasks.delete_item(item) + + {:noreply, assign(socket, :items, list_items())} + end + + defp list_items do + Tasks.list_items() + end +end diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex new file mode 100644 index 0000000..3d77c0c --- /dev/null +++ b/lib/app_web/live/item_live/index.html.heex @@ -0,0 +1,41 @@ +

Listing Items

+ +<%= if @live_action in [:new, :edit] do %> + <.modal return_to={Routes.item_index_path(@socket, :index)}> + <.live_component + module={AppWeb.ItemLive.FormComponent} + id={@item.id || :new} + title={@page_title} + action={@live_action} + item={@item} + return_to={Routes.item_index_path(@socket, :index)} + /> + +<% end %> + + + + + + + + + + + + <%= for item <- @items do %> + + + + + + + <% end %> + +
TextIndex
<%= item.text %><%= item.index %> + <%= live_redirect "Show", to: Routes.item_show_path(@socket, :show, item) %> + <%= live_patch "Edit", to: Routes.item_index_path(@socket, :edit, item) %> + <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: item.id, data: [confirm: "Are you sure?"] %> +
+ +<%= live_patch "New Item", to: Routes.item_index_path(@socket, :new) %> diff --git a/lib/app_web/live/item_live/show.ex b/lib/app_web/live/item_live/show.ex new file mode 100644 index 0000000..1b5e175 --- /dev/null +++ b/lib/app_web/live/item_live/show.ex @@ -0,0 +1,21 @@ +defmodule AppWeb.ItemLive.Show do + use AppWeb, :live_view + + alias App.Tasks + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:item, Tasks.get_item!(id))} + end + + defp page_title(:show), do: "Show Item" + defp page_title(:edit), do: "Edit Item" +end diff --git a/lib/app_web/live/item_live/show.html.heex b/lib/app_web/live/item_live/show.html.heex new file mode 100644 index 0000000..45ba17b --- /dev/null +++ b/lib/app_web/live/item_live/show.html.heex @@ -0,0 +1,31 @@ +

Show Item

+ +<%= if @live_action in [:edit] do %> + <.modal return_to={Routes.item_show_path(@socket, :show, @item)}> + <.live_component + module={AppWeb.ItemLive.FormComponent} + id={@item.id} + title={@page_title} + action={@live_action} + item={@item} + return_to={Routes.item_show_path(@socket, :show, @item)} + /> + +<% end %> + +
    + +
  • + Text: + <%= @item.text %> +
  • + +
  • + Index: + <%= @item.index %> +
  • + +
+ +<%= live_patch "Edit", to: Routes.item_show_path(@socket, :edit, @item), class: "button" %> | +<%= live_redirect "Back", to: Routes.item_index_path(@socket, :index) %> diff --git a/lib/app_web/live/live_helpers.ex b/lib/app_web/live/live_helpers.ex new file mode 100644 index 0000000..3a2ce60 --- /dev/null +++ b/lib/app_web/live/live_helpers.ex @@ -0,0 +1,61 @@ +defmodule AppWeb.LiveHelpers do + import Phoenix.LiveView + import Phoenix.LiveView.Helpers + import Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders a live component inside a modal. + + The rendered modal receives a `:return_to` option to properly update + the URL when the modal is closed. + + ## Examples + + <.modal return_to={Routes.item_index_path(@socket, :index)}> + <.live_component + module={AppWeb.ItemLive.FormComponent} + id={@item.id || :new} + title={@page_title} + action={@live_action} + return_to={Routes.item_index_path(@socket, :index)} + item: @item + /> + + """ + def modal(assigns) do + assigns = assign_new(assigns, :return_to, fn -> nil end) + + ~H""" + + """ + end + + defp hide_modal(js \\ %JS{}) do + js + |> JS.hide(to: "#modal", transition: "fade-out") + |> JS.hide(to: "#modal-content", transition: "fade-out-scale") + end +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index fd6c64c..caf3c84 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -18,6 +18,13 @@ defmodule AppWeb.Router do pipe_through :browser get "/", PageController, :index + + live "/items", ItemLive.Index, :index + live "/items/new", ItemLive.Index, :new + live "/items/:id/edit", ItemLive.Index, :edit + + live "/items/:id", ItemLive.Show, :show + live "/items/:id/show/edit", ItemLive.Show, :edit end # Other scopes may use custom stacks. diff --git a/priv/repo/migrations/20221022131733_create_items.exs b/priv/repo/migrations/20221022131733_create_items.exs new file mode 100644 index 0000000..27bb5c4 --- /dev/null +++ b/priv/repo/migrations/20221022131733_create_items.exs @@ -0,0 +1,12 @@ +defmodule App.Repo.Migrations.CreateItems do + use Ecto.Migration + + def change do + create table(:items) do + add :text, :string + add :index, :integer + + timestamps() + end + end +end diff --git a/test/app/tasks_test.exs b/test/app/tasks_test.exs new file mode 100644 index 0000000..331653d --- /dev/null +++ b/test/app/tasks_test.exs @@ -0,0 +1,61 @@ +defmodule App.TasksTest do + use App.DataCase + + alias App.Tasks + + describe "items" do + alias App.Tasks.Item + + import App.TasksFixtures + + @invalid_attrs %{index: nil, text: nil} + + test "list_items/0 returns all items" do + item = item_fixture() + assert Tasks.list_items() == [item] + end + + test "get_item!/1 returns the item with given id" do + item = item_fixture() + assert Tasks.get_item!(item.id) == item + end + + test "create_item/1 with valid data creates a item" do + valid_attrs = %{index: 42, text: "some text"} + + assert {:ok, %Item{} = item} = Tasks.create_item(valid_attrs) + assert item.index == 42 + assert item.text == "some text" + end + + test "create_item/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Tasks.create_item(@invalid_attrs) + end + + test "update_item/2 with valid data updates the item" do + item = item_fixture() + update_attrs = %{index: 43, text: "some updated text"} + + assert {:ok, %Item{} = item} = Tasks.update_item(item, update_attrs) + assert item.index == 43 + assert item.text == "some updated text" + end + + test "update_item/2 with invalid data returns error changeset" do + item = item_fixture() + assert {:error, %Ecto.Changeset{}} = Tasks.update_item(item, @invalid_attrs) + assert item == Tasks.get_item!(item.id) + end + + test "delete_item/1 deletes the item" do + item = item_fixture() + assert {:ok, %Item{}} = Tasks.delete_item(item) + assert_raise Ecto.NoResultsError, fn -> Tasks.get_item!(item.id) end + end + + test "change_item/1 returns a item changeset" do + item = item_fixture() + assert %Ecto.Changeset{} = Tasks.change_item(item) + end + end +end diff --git a/test/app_web/live/item_live_test.exs b/test/app_web/live/item_live_test.exs new file mode 100644 index 0000000..19da0e9 --- /dev/null +++ b/test/app_web/live/item_live_test.exs @@ -0,0 +1,110 @@ +defmodule AppWeb.ItemLiveTest do + use AppWeb.ConnCase + + import Phoenix.LiveViewTest + import App.TasksFixtures + + @create_attrs %{index: 42, text: "some text"} + @update_attrs %{index: 43, text: "some updated text"} + @invalid_attrs %{index: nil, text: nil} + + defp create_item(_) do + item = item_fixture() + %{item: item} + end + + describe "Index" do + setup [:create_item] + + test "lists all items", %{conn: conn, item: item} do + {:ok, _index_live, html} = live(conn, Routes.item_index_path(conn, :index)) + + assert html =~ "Listing Items" + assert html =~ item.text + end + + test "saves new item", %{conn: conn} do + {:ok, index_live, _html} = live(conn, Routes.item_index_path(conn, :index)) + + assert index_live |> element("a", "New Item") |> render_click() =~ + "New Item" + + assert_patch(index_live, Routes.item_index_path(conn, :new)) + + assert index_live + |> form("#item-form", item: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#item-form", item: @create_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.item_index_path(conn, :index)) + + assert html =~ "Item created successfully" + assert html =~ "some text" + end + + test "updates item in listing", %{conn: conn, item: item} do + {:ok, index_live, _html} = live(conn, Routes.item_index_path(conn, :index)) + + assert index_live |> element("#item-#{item.id} a", "Edit") |> render_click() =~ + "Edit Item" + + assert_patch(index_live, Routes.item_index_path(conn, :edit, item)) + + assert index_live + |> form("#item-form", item: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#item-form", item: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.item_index_path(conn, :index)) + + assert html =~ "Item updated successfully" + assert html =~ "some updated text" + end + + test "deletes item in listing", %{conn: conn, item: item} do + {:ok, index_live, _html} = live(conn, Routes.item_index_path(conn, :index)) + + assert index_live |> element("#item-#{item.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#item-#{item.id}") + end + end + + describe "Show" do + setup [:create_item] + + test "displays item", %{conn: conn, item: item} do + {:ok, _show_live, html} = live(conn, Routes.item_show_path(conn, :show, item)) + + assert html =~ "Show Item" + assert html =~ item.text + end + + test "updates item within modal", %{conn: conn, item: item} do + {:ok, show_live, _html} = live(conn, Routes.item_show_path(conn, :show, item)) + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Item" + + assert_patch(show_live, Routes.item_show_path(conn, :edit, item)) + + assert show_live + |> form("#item-form", item: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + show_live + |> form("#item-form", item: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.item_show_path(conn, :show, item)) + + assert html =~ "Item updated successfully" + assert html =~ "some updated text" + end + end +end diff --git a/test/support/fixtures/tasks_fixtures.ex b/test/support/fixtures/tasks_fixtures.ex new file mode 100644 index 0000000..bb99d34 --- /dev/null +++ b/test/support/fixtures/tasks_fixtures.ex @@ -0,0 +1,21 @@ +defmodule App.TasksFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `App.Tasks` context. + """ + + @doc """ + Generate a item. + """ + def item_fixture(attrs \\ %{}) do + {:ok, item} = + attrs + |> Enum.into(%{ + index: 42, + text: "some text" + }) + |> App.Tasks.create_item() + + item + end +end From 63c7d80adf5bf7cf53f6b3ea2009d8dcc4e6be30 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sun, 23 Oct 2022 14:16:03 +0100 Subject: [PATCH 07/33] Run formatter Run mix format --- .../live/item_live/form_component.html.heex | 24 +++++++++---------- lib/app_web/live/item_live/index.html.heex | 21 +++++++++++----- lib/app_web/live/item_live/show.html.heex | 8 +++---- lib/app_web/live/live_helpers.ex | 4 ++-- lib/app_web/router.ex | 5 +--- lib/app_web/templates/layout/app.html.heex | 2 -- lib/app_web/templates/layout/live.html.heex | 10 +------- 7 files changed, 35 insertions(+), 39 deletions(-) diff --git a/lib/app_web/live/item_live/form_component.html.heex b/lib/app_web/live/item_live/form_component.html.heex index 0d0ab52..baf4545 100644 --- a/lib/app_web/live/item_live/form_component.html.heex +++ b/lib/app_web/live/item_live/form_component.html.heex @@ -2,23 +2,23 @@

<%= @title %>

<.form - let={f} + :let={f} for={@changeset} id="item-form" phx-target={@myself} phx-change="validate" - phx-submit="save"> - - <%= label f, :text %> - <%= text_input f, :text %> - <%= error_tag f, :text %> - - <%= label f, :index %> - <%= number_input f, :index %> - <%= error_tag f, :index %> - + phx-submit="save" + > + <%= label(f, :text) %> + <%= text_input(f, :text) %> + <%= error_tag(f, :text) %> + + <%= label(f, :index) %> + <%= number_input(f, :index) %> + <%= error_tag(f, :index) %> +
- <%= submit "Save", phx_disable_with: "Saving..." %> + <%= submit("Save", phx_disable_with: "Saving...") %>
diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index 3d77c0c..c1b8d2a 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -1,4 +1,4 @@ -

Listing Items

+

Listing Items

<%= if @live_action in [:new, :edit] do %> <.modal return_to={Routes.item_index_path(@socket, :index)}> @@ -13,7 +13,7 @@ <% end %> - +
@@ -29,13 +29,22 @@ <% end %>
Text<%= item.index %> - <%= live_redirect "Show", to: Routes.item_show_path(@socket, :show, item) %> - <%= live_patch "Edit", to: Routes.item_index_path(@socket, :edit, item) %> - <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: item.id, data: [confirm: "Are you sure?"] %> + + <%= live_redirect("Show", to: Routes.item_show_path(@socket, :show, item)) %> + + <%= live_patch("Edit", to: Routes.item_index_path(@socket, :edit, item)) %> + + <%= link("Delete", + to: "#", + phx_click: "delete", + phx_value_id: item.id, + data: [confirm: "Are you sure?"] + ) %> +
-<%= live_patch "New Item", to: Routes.item_index_path(@socket, :new) %> +<%= live_patch("New Item", to: Routes.item_index_path(@socket, :new)) %> diff --git a/lib/app_web/live/item_live/show.html.heex b/lib/app_web/live/item_live/show.html.heex index 45ba17b..256a07b 100644 --- a/lib/app_web/live/item_live/show.html.heex +++ b/lib/app_web/live/item_live/show.html.heex @@ -14,7 +14,6 @@ <% end %>
    -
  • Text: <%= @item.text %> @@ -24,8 +23,9 @@ Index: <%= @item.index %>
  • -
-<%= live_patch "Edit", to: Routes.item_show_path(@socket, :edit, @item), class: "button" %> | -<%= live_redirect "Back", to: Routes.item_index_path(@socket, :index) %> + + <%= live_patch("Edit", to: Routes.item_show_path(@socket, :edit, @item), class: "button") %> + +| <%= live_redirect("Back", to: Routes.item_index_path(@socket, :index)) %> diff --git a/lib/app_web/live/live_helpers.ex b/lib/app_web/live/live_helpers.ex index 3a2ce60..74adef0 100644 --- a/lib/app_web/live/live_helpers.ex +++ b/lib/app_web/live/live_helpers.ex @@ -37,12 +37,12 @@ defmodule AppWeb.LiveHelpers do phx-key="escape" > <%= if @return_to do %> - <%= live_patch "✖", + <%= live_patch("✖", to: @return_to, id: "close", class: "phx-modal-close", phx_click: hide_modal() - %> + ) %> <% else %> <% end %> diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index caf3c84..b8b292e 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -16,10 +16,7 @@ defmodule AppWeb.Router do scope "/", AppWeb do pipe_through :browser - - get "/", PageController, :index - - live "/items", ItemLive.Index, :index + live "/", ItemLive.Index, :index live "/items/new", ItemLive.Index, :new live "/items/:id/edit", ItemLive.Index, :edit diff --git a/lib/app_web/templates/layout/app.html.heex b/lib/app_web/templates/layout/app.html.heex index 169aed9..365b597 100644 --- a/lib/app_web/templates/layout/app.html.heex +++ b/lib/app_web/templates/layout/app.html.heex @@ -1,5 +1,3 @@
- - <%= @inner_content %>
diff --git a/lib/app_web/templates/layout/live.html.heex b/lib/app_web/templates/layout/live.html.heex index 1829aab..de3803c 100644 --- a/lib/app_web/templates/layout/live.html.heex +++ b/lib/app_web/templates/layout/live.html.heex @@ -1,11 +1,3 @@ -
- - - - +
<%= @inner_content %>
From 784ab99f8d3ff2944a1191b6e53f39348c0bbabd Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sun, 23 Oct 2022 14:26:58 +0100 Subject: [PATCH 08/33] Create automatically index for item Create index when new item created --- lib/app/tasks.ex | 5 ++++- lib/app/tasks/item.ex | 2 +- .../live/item_live/form_component.html.heex | 4 ---- lib/app_web/live/item_live/index.html.heex | 17 ----------------- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/lib/app/tasks.ex b/lib/app/tasks.ex index 3e82b05..905d459 100644 --- a/lib/app/tasks.ex +++ b/lib/app/tasks.ex @@ -50,8 +50,11 @@ defmodule App.Tasks do """ def create_item(attrs \\ %{}) do + items = list_items() + index = length(items) + 1 + %Item{} - |> Item.changeset(attrs) + |> Item.changeset(Map.put(attrs, "index", index)) |> Repo.insert() end diff --git a/lib/app/tasks/item.ex b/lib/app/tasks/item.ex index dadd1b2..b905997 100644 --- a/lib/app/tasks/item.ex +++ b/lib/app/tasks/item.ex @@ -13,6 +13,6 @@ defmodule App.Tasks.Item do def changeset(item, attrs) do item |> cast(attrs, [:text, :index]) - |> validate_required([:text, :index]) + |> validate_required([:text]) end end diff --git a/lib/app_web/live/item_live/form_component.html.heex b/lib/app_web/live/item_live/form_component.html.heex index baf4545..fd51c9d 100644 --- a/lib/app_web/live/item_live/form_component.html.heex +++ b/lib/app_web/live/item_live/form_component.html.heex @@ -13,10 +13,6 @@ <%= text_input(f, :text) %> <%= error_tag(f, :text) %> - <%= label(f, :index) %> - <%= number_input(f, :index) %> - <%= error_tag(f, :index) %> -
<%= submit("Save", phx_disable_with: "Saving...") %>
diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index c1b8d2a..dd99f13 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -18,8 +18,6 @@ Text Index - - @@ -27,21 +25,6 @@ <%= item.text %> <%= item.index %> - - - - <%= live_redirect("Show", to: Routes.item_show_path(@socket, :show, item)) %> - - <%= live_patch("Edit", to: Routes.item_index_path(@socket, :edit, item)) %> - - <%= link("Delete", - to: "#", - phx_click: "delete", - phx_value_id: item.id, - data: [confirm: "Are you sure?"] - ) %> - - <% end %> From 38ccca842a086657cd33b1d02c0547cf09bbe601 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sun, 23 Oct 2022 20:19:55 +0100 Subject: [PATCH 09/33] Use Petal UI component Update modal form to create item --- assets/css/app.css | 63 ++++++----------- assets/tailwind.config.js | 29 +++++--- lib/app_web.ex | 1 + .../live/item_live/form_component.html.heex | 11 +-- lib/app_web/live/item_live/index.ex | 6 ++ lib/app_web/live/item_live/index.html.heex | 31 +++++---- lib/app_web/live/live_helpers.ex | 68 +++++++++---------- lib/app_web/router.ex | 1 + lib/app_web/templates/layout/live.html.heex | 20 +++++- mix.exs | 3 +- mix.lock | 2 + 11 files changed, 123 insertions(+), 112 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 04efcc9..3e43892 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -53,53 +53,38 @@ cursor: wait; } -.phx-modal { - opacity: 1!important; - position: fixed; - z-index: 1; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; - background-color: rgba(0,0,0,0.4); + +.select-wrapper select { + @apply text-sm border-gray-300 rounded-md shadow-sm disabled:bg-gray-100 disabled:cursor-not-allowed focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:focus:border-primary-500 dark:bg-gray-800 dark:text-gray-300 focus:outline-none ; } -.phx-modal-content { - background-color: #fefefe; - margin: 15vh auto; - padding: 20px; - border: 1px solid #888; - width: 80%; +label.has-error:not(.phx-no-feedback) { + @apply !text-red-900 dark:!text-red-200; } -.phx-modal-close { - color: #aaa; - float: right; - font-size: 28px; - font-weight: bold; +textarea.has-error:not(.phx-no-feedback), input.has-error:not(.phx-no-feedback), select.has-error:not(.phx-no-feedback) { + @apply !border-red-500 focus:!border-red-500 !text-red-900 !placeholder-red-700 !bg-red-50 dark:!text-red-100 dark:!placeholder-red-300 dark:!bg-red-900 focus:!ring-red-500; } -.phx-modal-close:hover, -.phx-modal-close:focus { - color: black; - text-decoration: none; - cursor: pointer; +input[type=file_input].has-error:not(.phx-no-feedback) { + @apply !border-red-500 !rounded-md focus:!border-red-500 !text-red-900 !placeholder-red-700 !bg-red-50 file:!border-none dark:!border-none dark:!bg-[#160B0B] dark:text-red-400; } -.fade-in-scale { - animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; +input[type=checkbox].has-error:not(.phx-no-feedback) { + @apply !border-red-500 !text-red-900 dark:!text-red-200; } -.fade-out-scale { - animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; +input[type=radio].has-error:not(.phx-no-feedback) { + @apply !border-red-500; } -.fade-in { - animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; +/* Modal animation */ +.animate-fade-in-scale { + animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; } -.fade-out { - animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; + +.animate-fade-in { + animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; } @keyframes fade-in-scale-keys{ @@ -107,18 +92,8 @@ 100% { scale: 1.0; opacity: 1; } } -@keyframes fade-out-scale-keys{ - 0% { scale: 1.0; opacity: 1; } - 100% { scale: 0.95; opacity: 0; } -} - @keyframes fade-in-keys{ 0% { opacity: 0; } 100% { opacity: 1; } } -@keyframes fade-out-keys{ - 0% { opacity: 1; } - 100% { opacity: 0; } -} - diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 76fe451..d135f69 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -1,22 +1,33 @@ // See the Tailwind configuration guide for advanced usage // https://tailwindcss.com/docs/configuration - +const colors = require("tailwindcss/colors"); let plugin = require('tailwindcss/plugin') module.exports = { content: [ - './js/**/*.js', - '../lib/*_web.ex', - '../lib/*_web/**/*.*ex' + "../lib/*_web.ex", + "../lib/*_web/**/*.*ex", + "./js/**/*.js", + "../deps/petal_components/**/*.*ex", ], + darkMode: "class", theme: { - extend: {}, + extend: { + colors: { + primary: colors.blue, + secondary: colors.pink, + }, + }, }, - plugins: [ - require('@tailwindcss/forms'), + plugins: [require("@tailwindcss/forms"), + plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) - ] -} + ], +}; + + + + diff --git a/lib/app_web.ex b/lib/app_web.ex index e801cec..6699e7f 100644 --- a/lib/app_web.ex +++ b/lib/app_web.ex @@ -98,6 +98,7 @@ defmodule AppWeb do import AppWeb.ErrorHelpers alias AppWeb.Router.Helpers, as: Routes + use PetalComponents end end diff --git a/lib/app_web/live/item_live/form_component.html.heex b/lib/app_web/live/item_live/form_component.html.heex index fd51c9d..ae605ae 100644 --- a/lib/app_web/live/item_live/form_component.html.heex +++ b/lib/app_web/live/item_live/form_component.html.heex @@ -1,6 +1,4 @@
-

<%= @title %>

- <.form :let={f} for={@changeset} @@ -9,12 +7,7 @@ phx-change="validate" phx-submit="save" > - <%= label(f, :text) %> - <%= text_input(f, :text) %> - <%= error_tag(f, :text) %> - -
- <%= submit("Save", phx_disable_with: "Saving...") %> -
+ <.form_field type="text_input" form={f} field={:text} placeholder="item" /> + <.button label="Save" phx_disable_with="Saving..." />
diff --git a/lib/app_web/live/item_live/index.ex b/lib/app_web/live/item_live/index.ex index 5456176..cb9e076 100644 --- a/lib/app_web/live/item_live/index.ex +++ b/lib/app_web/live/item_live/index.ex @@ -40,6 +40,12 @@ defmodule AppWeb.ItemLive.Index do {:noreply, assign(socket, :items, list_items())} end + @impl true + def handle_event("close_modal", _, socket) do + # Go back to the :index live action + {:noreply, push_patch(socket, to: "/")} + end + defp list_items do Tasks.list_items() end diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index dd99f13..b28ccbd 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -1,7 +1,7 @@ -

Listing Items

+<.h1 class="text-lg text-center font-bold">Listing Items <%= if @live_action in [:new, :edit] do %> - <.modal return_to={Routes.item_index_path(@socket, :index)}> + <.modal return_to={Routes.item_index_path(@socket, :index)} title="Item"> <.live_component module={AppWeb.ItemLive.FormComponent} id={@item.id || :new} @@ -13,21 +13,26 @@ <% end %> - +<.table class="mt-3"> - - - - + <.tr> + <.th>Text + <.th>Index + <%= for item <- @items do %> - - - - + <.tr id={"item-#{item.id}"}> + <.td><%= item.text %> + <.td><%= item.index %> + <% end %> -
TextIndex
<%= item.text %><%= item.index %>
+ -<%= live_patch("New Item", to: Routes.item_index_path(@socket, :new)) %> +<.button + link_type="live_patch" + to={Routes.item_index_path(@socket, :new)} + class="mt-3" + label="New Item" +/> diff --git a/lib/app_web/live/live_helpers.ex b/lib/app_web/live/live_helpers.ex index 74adef0..144cd7d 100644 --- a/lib/app_web/live/live_helpers.ex +++ b/lib/app_web/live/live_helpers.ex @@ -24,38 +24,38 @@ defmodule AppWeb.LiveHelpers do /> """ - def modal(assigns) do - assigns = assign_new(assigns, :return_to, fn -> nil end) - - ~H""" - - """ - end - - defp hide_modal(js \\ %JS{}) do - js - |> JS.hide(to: "#modal", transition: "fade-out") - |> JS.hide(to: "#modal-content", transition: "fade-out-scale") - end + # def modal(assigns) do + # assigns = assign_new(assigns, :return_to, fn -> nil end) + + # ~H""" + # + # """ + # end + + # defp hide_modal(js \\ %JS{}) do + # js + # |> JS.hide(to: "#modal", transition: "fade-out") + # |> JS.hide(to: "#modal-content", transition: "fade-out-scale") + # end end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index b8b292e..3690187 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -17,6 +17,7 @@ defmodule AppWeb.Router do scope "/", AppWeb do pipe_through :browser live "/", ItemLive.Index, :index + live "/items", ItemLive.Index, :index live "/items/new", ItemLive.Index, :new live "/items/:id/edit", ItemLive.Index, :edit diff --git a/lib/app_web/templates/layout/live.html.heex b/lib/app_web/templates/layout/live.html.heex index de3803c..d96e9d3 100644 --- a/lib/app_web/templates/layout/live.html.heex +++ b/lib/app_web/templates/layout/live.html.heex @@ -1,3 +1,19 @@ -
+<.container class="my-10"> + <.alert + color="info" + class="mb-5" + label={live_flash(@flash, :info)} + phx-click="lv:clear-flash" + phx-value-key="info" + /> + + <.alert + color="danger" + class="mb-5" + label={live_flash(@flash, :error)} + phx-click="lv:clear-flash" + phx-value-key="error" + /> + <%= @inner_content %> -
+ diff --git a/mix.exs b/mix.exs index ce82949..7023ac3 100644 --- a/mix.exs +++ b/mix.exs @@ -46,7 +46,8 @@ defmodule App.MixProject do {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, {:plug_cowboy, "~> 2.5"}, - {:tailwind, "~> 0.1", runtime: Mix.env() == :dev} + {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, + {:petal_components, "~> 0.18"} ] end diff --git a/mix.lock b/mix.lock index d25872a..49b0327 100644 --- a/mix.lock +++ b/mix.lock @@ -11,9 +11,11 @@ "esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"}, + "heroicons": {:hex, :heroicons, "0.5.1", "cca0dcca07af5f74d8a7d111e40418d3615d65e6773c0ea10e20cef070fd30aa", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4b096d0a1d50e9054df9b12cc637c9f65c3972ff086791d3f2d1846f0653117e"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "petal_components": {:hex, :petal_components, "0.18.5", "f7abd370d179e8ab1eff491a7a85526984ead78b41190b03f6ec5df04932cdbf", [:mix], [{:heroicons, "~> 0.5.0", [hex: :heroicons, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "d38982b0e9fa39884cfaf5e49bdc77a6be16b17b73fc19001b2a27fe4b672dca"}, "phoenix": {:hex, :phoenix, "1.6.14", "57678366dc1d5bad49832a0fc7f12c2830c10d3eacfad681bfe9602cd4445f04", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d48c0da00b3d4cd1aad6055387917491af9f6e1f1e96cedf6c6b7998df9dba26"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, From 01091a4064734f33976adf25c7b315cf8858a0c4 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 24 Oct 2022 07:00:59 +0100 Subject: [PATCH 10/33] Update tests Update and remove unused tests --- test/app/tasks_test.exs | 8 ++- test/app_web/live/item_live_test.exs | 70 +------------------------ test/support/fixtures/tasks_fixtures.ex | 3 +- 3 files changed, 5 insertions(+), 76 deletions(-) diff --git a/test/app/tasks_test.exs b/test/app/tasks_test.exs index 331653d..a8d9cb2 100644 --- a/test/app/tasks_test.exs +++ b/test/app/tasks_test.exs @@ -8,7 +8,7 @@ defmodule App.TasksTest do import App.TasksFixtures - @invalid_attrs %{index: nil, text: nil} + @invalid_attrs %{"text" => nil} test "list_items/0 returns all items" do item = item_fixture() @@ -21,10 +21,9 @@ defmodule App.TasksTest do end test "create_item/1 with valid data creates a item" do - valid_attrs = %{index: 42, text: "some text"} + valid_attrs = %{"text" => "some text"} assert {:ok, %Item{} = item} = Tasks.create_item(valid_attrs) - assert item.index == 42 assert item.text == "some text" end @@ -34,10 +33,9 @@ defmodule App.TasksTest do test "update_item/2 with valid data updates the item" do item = item_fixture() - update_attrs = %{index: 43, text: "some updated text"} + update_attrs = %{"text" => "some updated text"} assert {:ok, %Item{} = item} = Tasks.update_item(item, update_attrs) - assert item.index == 43 assert item.text == "some updated text" end diff --git a/test/app_web/live/item_live_test.exs b/test/app_web/live/item_live_test.exs index 19da0e9..76f8dad 100644 --- a/test/app_web/live/item_live_test.exs +++ b/test/app_web/live/item_live_test.exs @@ -4,9 +4,7 @@ defmodule AppWeb.ItemLiveTest do import Phoenix.LiveViewTest import App.TasksFixtures - @create_attrs %{index: 42, text: "some text"} - @update_attrs %{index: 43, text: "some updated text"} - @invalid_attrs %{index: nil, text: nil} + @create_attrs %{"text" => "some text"} defp create_item(_) do item = item_fixture() @@ -31,10 +29,6 @@ defmodule AppWeb.ItemLiveTest do assert_patch(index_live, Routes.item_index_path(conn, :new)) - assert index_live - |> form("#item-form", item: @invalid_attrs) - |> render_change() =~ "can't be blank" - {:ok, _, html} = index_live |> form("#item-form", item: @create_attrs) @@ -44,67 +38,5 @@ defmodule AppWeb.ItemLiveTest do assert html =~ "Item created successfully" assert html =~ "some text" end - - test "updates item in listing", %{conn: conn, item: item} do - {:ok, index_live, _html} = live(conn, Routes.item_index_path(conn, :index)) - - assert index_live |> element("#item-#{item.id} a", "Edit") |> render_click() =~ - "Edit Item" - - assert_patch(index_live, Routes.item_index_path(conn, :edit, item)) - - assert index_live - |> form("#item-form", item: @invalid_attrs) - |> render_change() =~ "can't be blank" - - {:ok, _, html} = - index_live - |> form("#item-form", item: @update_attrs) - |> render_submit() - |> follow_redirect(conn, Routes.item_index_path(conn, :index)) - - assert html =~ "Item updated successfully" - assert html =~ "some updated text" - end - - test "deletes item in listing", %{conn: conn, item: item} do - {:ok, index_live, _html} = live(conn, Routes.item_index_path(conn, :index)) - - assert index_live |> element("#item-#{item.id} a", "Delete") |> render_click() - refute has_element?(index_live, "#item-#{item.id}") - end - end - - describe "Show" do - setup [:create_item] - - test "displays item", %{conn: conn, item: item} do - {:ok, _show_live, html} = live(conn, Routes.item_show_path(conn, :show, item)) - - assert html =~ "Show Item" - assert html =~ item.text - end - - test "updates item within modal", %{conn: conn, item: item} do - {:ok, show_live, _html} = live(conn, Routes.item_show_path(conn, :show, item)) - - assert show_live |> element("a", "Edit") |> render_click() =~ - "Edit Item" - - assert_patch(show_live, Routes.item_show_path(conn, :edit, item)) - - assert show_live - |> form("#item-form", item: @invalid_attrs) - |> render_change() =~ "can't be blank" - - {:ok, _, html} = - show_live - |> form("#item-form", item: @update_attrs) - |> render_submit() - |> follow_redirect(conn, Routes.item_show_path(conn, :show, item)) - - assert html =~ "Item updated successfully" - assert html =~ "some updated text" - end end end diff --git a/test/support/fixtures/tasks_fixtures.ex b/test/support/fixtures/tasks_fixtures.ex index bb99d34..04639ed 100644 --- a/test/support/fixtures/tasks_fixtures.ex +++ b/test/support/fixtures/tasks_fixtures.ex @@ -11,8 +11,7 @@ defmodule App.TasksFixtures do {:ok, item} = attrs |> Enum.into(%{ - index: 42, - text: "some text" + "text" => "some text" }) |> App.Tasks.create_item() From 15cd7a08ab1c71959c437344c9657c4a1c93bbb2 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 24 Oct 2022 13:33:31 +0100 Subject: [PATCH 11/33] Implement drag and drop with vanilla js First attempt at drag and drop with vanilla js version --- README.md | 20 +++++++ assets/js/app.js | 62 ++++++++++++++++++++++ lib/app_web/live/item_live/index.html.heex | 2 +- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d601da3..333d18d 100644 --- a/README.md +++ b/README.md @@ -101,5 +101,25 @@ mix phx.new . --app app --no-dashboard --no-gettext --no-mailer ``` Then we install Tailwind, see https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix +and add Petal Component, this to create the UI without starting from scratch. + +We can use `mix gen.live Tasks Item items text:string index:integer` to let Phoenix +create the structure for the live items' page. + +We can now focus on using the drag and drop html feature. + +Add the draggable attribute + +see: +- https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API +- https://www.youtube.com/watch?v=jfYWwQrtzzY + + + + + + + + diff --git a/assets/js/app.js b/assets/js/app.js index bf203ba..7b7e578 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -42,3 +42,65 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket +// Drag and Drop JS version: + +// Select all the items that are draggable +// and the list of items where we can move an item to. +const draggables = document.querySelectorAll(".draggable"); +const listItems = document.querySelector("#items"); + +// For all items add the `dragstart` event listener +draggables.forEach(dragable => { + dragable.addEventListener('dragstart', () => { + dragable.classList.add('bg-red-300', 'dragging') + }); + + dragable.addEventListener('dragend', () => { + dragable.classList.remove('bg-red-300', 'dragging') + }); +}) + +listItems.addEventListener('dragover', e => { + e.preventDefault() + const draggable = document.querySelector('.dragging') + const nextItem = getNextItem(e.clientY) + console.log(nextItem) + if (nextItem == null) { + listItems.appendChild(draggable) + } else { + listItems.insertBefore(draggable, nextItem) + } +}) + +function getNextItem(y) { + const draggables = [...document.querySelectorAll(".draggable:not(.dragging)")] + return draggables.reduce(function(nextItem, currentItem) { + const box = currentItem.getBoundingClientRect() + const offset = y - (box.y - (box.height / 2)) + + if (offset < 0 && offset > nextItem.offset) { + return {offset: offset, element: currentItem} + } else { + return nextItem + } + }, {offset: Number.NEGATIVE_INFINITY}).element +} + + + + + + + + + + + + + + + + + + + diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index b28ccbd..b2ea7c3 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -22,7 +22,7 @@ <%= for item <- @items do %> - <.tr id={"item-#{item.id}"}> + <.tr id={"item-#{item.id}"} draggable="true" class="draggable cursor-move"> <.td><%= item.text %> <.td><%= item.index %> From eeba5be58495b990dde0b15a55f46c47823c3010 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 24 Oct 2022 16:10:17 +0100 Subject: [PATCH 12/33] Create working example with js Drag and drop with javascript --- assets/js/app.js | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 7b7e578..225634d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,7 +1,7 @@ // We import the CSS which is extracted to its own file by esbuild. // Remove this line if you add a your own CSS build pipeline (e.g postcss). -// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// If you want to use Phoenix channels, run `mix help phx.gen.channeItem 2l` // to get started and then uncomment the line below. // import "./user_socket.js" @@ -62,30 +62,35 @@ draggables.forEach(dragable => { listItems.addEventListener('dragover', e => { e.preventDefault() - const draggable = document.querySelector('.dragging') - const nextItem = getNextItem(e.clientY) - console.log(nextItem) - if (nextItem == null) { - listItems.appendChild(draggable) + const dragged = document.querySelector('.dragging') + const overItem = getOverItem(e.clientY) + const moving = direction(dragged, overItem) + if (moving == "down") { + listItems.insertBefore(dragged, overItem.nextSibling) } else { - listItems.insertBefore(draggable, nextItem) + listItems.insertBefore(dragged, overItem) } }) -function getNextItem(y) { - const draggables = [...document.querySelectorAll(".draggable:not(.dragging)")] - return draggables.reduce(function(nextItem, currentItem) { - const box = currentItem.getBoundingClientRect() - const offset = y - (box.y - (box.height / 2)) - - if (offset < 0 && offset > nextItem.offset) { - return {offset: offset, element: currentItem} - } else { - return nextItem - } - }, {offset: Number.NEGATIVE_INFINITY}).element + +function getOverItem(y) { + const draggables = [...document.querySelectorAll(".draggable")] + return draggables.find( item => { + const box = item.getBoundingClientRect() + return y > box.top && y < box.bottom + }) } +function direction(dragged, overItem) { + const draggables = [...document.querySelectorAll(".draggable")] + if (draggables.indexOf(dragged) < draggables.indexOf(overItem)) { + return "down" + } else { + return "up" + } +} + + From 43ae038b13a336b1de5efa0113a1a85aabed1f0f Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 24 Oct 2022 16:27:43 +0100 Subject: [PATCH 13/33] Turn down background color use bg-red-100 to see moving item --- assets/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 225634d..8c950c6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -52,11 +52,11 @@ const listItems = document.querySelector("#items"); // For all items add the `dragstart` event listener draggables.forEach(dragable => { dragable.addEventListener('dragstart', () => { - dragable.classList.add('bg-red-300', 'dragging') + dragable.classList.add('bg-red-100', 'dragging') }); dragable.addEventListener('dragend', () => { - dragable.classList.remove('bg-red-300', 'dragging') + dragable.classList.remove('bg-red-100', 'dragging') }); }) From 2338f733e1c123eb830d31b61f3300a19e5e0beb Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 24 Oct 2022 22:10:55 +0100 Subject: [PATCH 14/33] Define dragstart and dragend events with alpine Set background color when an element is moved --- assets/js/app.js | 121 +++++++++----------- assets/vendor/alpine.js | 5 + lib/app_web/live/item_live/index.html.heex | 12 +- lib/app_web/templates/layout/root.html.heex | 2 - 4 files changed, 72 insertions(+), 68 deletions(-) create mode 100644 assets/vendor/alpine.js diff --git a/assets/js/app.js b/assets/js/app.js index 8c950c6..d6c20b2 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,9 +24,19 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" +import Alpine from "../vendor/alpine" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +let liveSocket = new LiveSocket("/live", Socket, { + dom: { + onBeforeElUpdated(from, to) { + if (from._x_dataStack) { + window.Alpine.clone(from, to) + } + } + }, + params: {_csrf_token: csrfToken} +}) // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) @@ -42,70 +52,53 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket + + // Drag and Drop JS version: // Select all the items that are draggable // and the list of items where we can move an item to. -const draggables = document.querySelectorAll(".draggable"); -const listItems = document.querySelector("#items"); - -// For all items add the `dragstart` event listener -draggables.forEach(dragable => { - dragable.addEventListener('dragstart', () => { - dragable.classList.add('bg-red-100', 'dragging') - }); - - dragable.addEventListener('dragend', () => { - dragable.classList.remove('bg-red-100', 'dragging') - }); -}) - -listItems.addEventListener('dragover', e => { - e.preventDefault() - const dragged = document.querySelector('.dragging') - const overItem = getOverItem(e.clientY) - const moving = direction(dragged, overItem) - if (moving == "down") { - listItems.insertBefore(dragged, overItem.nextSibling) - } else { - listItems.insertBefore(dragged, overItem) - } -}) - - -function getOverItem(y) { - const draggables = [...document.querySelectorAll(".draggable")] - return draggables.find( item => { - const box = item.getBoundingClientRect() - return y > box.top && y < box.bottom - }) -} - -function direction(dragged, overItem) { - const draggables = [...document.querySelectorAll(".draggable")] - if (draggables.indexOf(dragged) < draggables.indexOf(overItem)) { - return "down" - } else { - return "up" - } -} - - - - - - - - - - - - - - - - - - - - +// +// const draggables = document.querySelectorAll(".draggable"); +// const listItems = document.querySelector("#items"); +// +// draggables.forEach(dragable => { +// dragable.addEventListener('dragstart', () => { +// dragable.classList.add('bg-red-100', 'dragging') +// }); +// +// dragable.addEventListener('dragend', () => { +// dragable.classList.remove('bg-red-100', 'dragging') +// }); +// }) +// +// listItems.addEventListener('dragover', e => { +// e.preventDefault() +// const dragged = document.querySelector('.dragging') +// const overItem = getOverItem(e.clientY) +// const moving = direction(dragged, overItem) +// if (moving == "down") { +// listItems.insertBefore(dragged, overItem.nextSibling) +// } +// +// if (moving == "up"){ +// listItems.insertBefore(dragged, overItem) +// } +// }) +// +// function getOverItem(y) { +// const draggables = [...document.querySelectorAll(".draggable")] +// return draggables.find( item => { +// const box = item.getBoundingClientRect() +// return y > box.top && y < box.bottom +// }) +// } +// +// function direction(dragged, overItem) { +// const draggables = [...document.querySelectorAll(".draggable")] +// if (draggables.indexOf(dragged) < draggables.indexOf(overItem)) { +// return "down" +// } else { +// return "up" +// } +// } diff --git a/assets/vendor/alpine.js b/assets/vendor/alpine.js new file mode 100644 index 0000000..d213dfc --- /dev/null +++ b/assets/vendor/alpine.js @@ -0,0 +1,5 @@ +(()=>{var We=!1,Ge=!1,B=[];function $t(e){an(e)}function an(e){B.includes(e)||B.push(e),cn()}function he(e){let t=B.indexOf(e);t!==-1&&B.splice(t,1)}function cn(){!Ge&&!We&&(We=!0,queueMicrotask(ln))}function ln(){We=!1,Ge=!0;for(let e=0;ee.effect(t,{scheduler:r=>{Je?$t(r):r()}}),Ye=e.raw}function Ze(e){K=e}function Ft(e){let t=()=>{};return[n=>{let i=K(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),Y(i))},i},()=>{t()}]}var Bt=[],Kt=[],zt=[];function Vt(e){zt.push(e)}function _e(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Kt.push(t))}function Ht(e){Bt.push(e)}function qt(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Qe(e,t){!e._x_attributeCleanups||Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var et=new MutationObserver(Xe),tt=!1;function rt(){et.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),tt=!0}function fn(){un(),et.disconnect(),tt=!1}var te=[],nt=!1;function un(){te=te.concat(et.takeRecords()),te.length&&!nt&&(nt=!0,queueMicrotask(()=>{dn(),nt=!1}))}function dn(){Xe(te),te.length=0}function m(e){if(!tt)return e();fn();let t=e();return rt(),t}var it=!1,ge=[];function Ut(){it=!0}function Wt(){it=!1,Xe(ge),ge=[]}function Xe(e){if(it){ge=ge.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{Qe(s,o)}),n.forEach((o,s)=>{Bt.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(Kt.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||!o.isConnected||(delete o._x_ignoreSelf,delete o._x_ignore,zt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function xe(e){return D(k(e))}function C(e,t,r){return e._x_dataStack=[t,...k(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function ot(e,t){let r=e._x_dataStack[0];Object.entries(t).forEach(([n,i])=>{r[n]=i})}function k(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?k(e.host):e.parentNode?k(e.parentNode):[]}function D(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,c=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...c,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function ye(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function be(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>pn(n,i),s=>st(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function pn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function st(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),st(e[t[0]],t.slice(1),r)}}var Gt={};function x(e,t){Gt[e]=t}function re(e,t){return Object.entries(Gt).forEach(([r,n])=>{Object.defineProperty(e,`$${r}`,{get(){let[i,o]=at(t);return i={interceptor:be,...i},_e(t,o),n(t,i)},enumerable:!1})}),e}function Yt(e,t,r,...n){try{return r(...n)}catch(i){J(i,e,t)}}function J(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var ve=!0;function Jt(e){let t=ve;ve=!1,e(),ve=t}function P(e,t,r={}){let n;return g(e,t)(i=>n=i,r),n}function g(...e){return Zt(...e)}var Zt=ct;function Qt(e){Zt=e}function ct(e,t){let r={};re(r,e);let n=[r,...k(e)];if(typeof t=="function")return mn(n,t);let i=hn(n,t,e);return Yt.bind(null,e,t,i)}function mn(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(D([n,...e]),i);we(r,o)}}var lt={};function _n(e,t){if(lt[e])return lt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e)||/^(let|const)\s/.test(e)?`(() => { ${e} })()`:e,o=(()=>{try{return new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`)}catch(s){return J(s,t,e),Promise.resolve()}})();return lt[e]=o,o}function hn(e,t,r){let n=_n(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=D([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>J(l,r,t));n.finished?(we(i,n.result,a,s,r),n.result=void 0):c.then(l=>{we(i,l,a,s,r)}).catch(l=>J(l,r,t)).finally(()=>n.result=void 0)}}}function we(e,t,r,n,i){if(ve&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>we(e,s,r,n)).catch(s=>J(s,i,t)):e(o)}else e(t)}var ut="x-";function E(e=""){return ut+e}function Xt(e){ut=e}var er={};function d(e,t){er[e]=t}function ne(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=ft(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(tr((o,s)=>n[o]=s)).filter(rr).map(xn(n,r)).sort(yn).map(o=>gn(e,o))}function ft(e){return Array.from(e).map(tr()).filter(t=>!rr(t))}var dt=!1,ie=new Map,nr=Symbol();function ir(e){dt=!0;let t=Symbol();nr=t,ie.set(t,[]);let r=()=>{for(;ie.get(t).length;)ie.get(t).shift()();ie.delete(t)},n=()=>{dt=!1,r()};e(r),n()}function at(e){let t=[],r=a=>t.push(a),[n,i]=Ft(e);return t.push(i),[{Alpine:I,effect:n,cleanup:r,evaluateLater:g.bind(g,e),evaluate:P.bind(P,e)},()=>t.forEach(a=>a())]}function gn(e,t){let r=()=>{},n=er[t.type]||r,[i,o]=at(e);qt(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),dt?ie.get(nr).push(n):n())};return s.runCleanups=o,s}var Ee=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Se=e=>e;function tr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=or.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var or=[];function Z(e){or.push(e)}function rr({name:e}){return sr().test(e)}var sr=()=>new RegExp(`^${ut}([^:^.]+)\\b`);function xn(e,t){return({name:r,value:n})=>{let i=r.match(sr()),o=r.match(/:([a-zA-Z0-9\-:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var pt="DEFAULT",Ae=["ignore","ref","data","id","tabs","radio","switch","disclosure","bind","init","for","mask","model","modelable","transition","show","if",pt,"teleport"];function yn(e,t){let r=Ae.indexOf(e.type)===-1?pt:e.type,n=Ae.indexOf(t.type)===-1?pt:t.type;return Ae.indexOf(r)-Ae.indexOf(n)}function z(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}var mt=[],ht=!1;function Te(e=()=>{}){return queueMicrotask(()=>{ht||setTimeout(()=>{Oe()})}),new Promise(t=>{mt.push(()=>{e(),t()})})}function Oe(){for(ht=!1;mt.length;)mt.shift()()}function ar(){ht=!0}function R(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>R(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)R(n,t,!1),n=n.nextElementSibling}function O(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}function lr(){document.body||O("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` -
From 564d4ce28fe93b5001b06551c2ebb42e1db09328 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 26 Oct 2022 09:10:36 +0100 Subject: [PATCH 15/33] First Alpine.js drag and drop version Use Alpine.js to drag and drop an item in a list --- assets/js/app.js | 1 - lib/app_web/live/item_live/index.html.heex | 36 +++++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index d6c20b2..bba7e49 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -53,7 +53,6 @@ liveSocket.connect() window.liveSocket = liveSocket - // Drag and Drop JS version: // Select all the items that are draggable diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index 8be3808..80c76a1 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -20,18 +20,21 @@ <.th>Index - + <%= for item <- @items do %> <.tr id={"item-#{item.id}"} draggable="true" - class="draggable cursor-move" - x-data="{dragging: false}" - x-on:dragstart.self="dragging = true" - x-on:dragend.self="dragging = false" - x-bind:class="dragging ? 'bg-red-100' : ''" + class="draggable" + x-data="dropdown" + x-on:dragstart.self="dragging = true; dragged = $el;" + x-on:dragend.self="dragging = false; dragover = false; dragged = null" + @dragover="dragover = true; dragElt(dragged, $el)" + @dragleave="dragover = false" + @drop="dragover = false" + x-bind:class="dragover ? '!bg-green-100' : (dragging && '!bg-red-100')" > - <.td><%= item.text %> + <.td class=""><%= item.text %> <.td><%= item.index %> <% end %> @@ -44,3 +47,22 @@ class="mt-3" label="New Item" /> + + From 62447c9a157cdc4b8697aed40199912ec0f70616 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 26 Oct 2022 11:00:03 +0100 Subject: [PATCH 16/33] Add PubSub to notify clients for new item_live Use PubSub to listen for the :item_created event --- lib/app/tasks.ex | 17 +++++++++++++++++ lib/app_web/live/item_live/form_component.ex | 2 +- lib/app_web/live/item_live/index.ex | 8 ++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/app/tasks.ex b/lib/app/tasks.ex index 905d459..b6d081b 100644 --- a/lib/app/tasks.ex +++ b/lib/app/tasks.ex @@ -7,6 +7,22 @@ defmodule App.Tasks do alias App.Repo alias App.Tasks.Item + alias Phoenix.PubSub + + # PubSub functions + + + def subscribe() do + PubSub.subscribe(App.PubSub, "liveview_items") + end + + def notify({:ok, message}, event) do + PubSub.broadcast(App.PubSub, "liveview_items", {event, message}) + end + + def notify({:error, reason}, _event), do: {:error, reason} + + @doc """ Returns the list of items. @@ -56,6 +72,7 @@ defmodule App.Tasks do %Item{} |> Item.changeset(Map.put(attrs, "index", index)) |> Repo.insert() + |> notify(:item_created) end @doc """ diff --git a/lib/app_web/live/item_live/form_component.ex b/lib/app_web/live/item_live/form_component.ex index 379f721..baea30a 100644 --- a/lib/app_web/live/item_live/form_component.ex +++ b/lib/app_web/live/item_live/form_component.ex @@ -42,7 +42,7 @@ defmodule AppWeb.ItemLive.FormComponent do defp save_item(socket, :new, item_params) do case Tasks.create_item(item_params) do - {:ok, _item} -> + :ok -> {:noreply, socket |> put_flash(:info, "Item created successfully") diff --git a/lib/app_web/live/item_live/index.ex b/lib/app_web/live/item_live/index.ex index cb9e076..f453518 100644 --- a/lib/app_web/live/item_live/index.ex +++ b/lib/app_web/live/item_live/index.ex @@ -6,6 +6,7 @@ defmodule AppWeb.ItemLive.Index do @impl true def mount(_params, _session, socket) do + if connected?(socket), do: Tasks.subscribe() {:ok, assign(socket, :items, list_items())} end @@ -46,6 +47,13 @@ defmodule AppWeb.ItemLive.Index do {:noreply, push_patch(socket, to: "/")} end + @impl true + def handle_info({:item_created, _item}, socket) do + items = list_items() + # messages = socket.assigns.messages ++ [message] + {:noreply, assign(socket, items: items)} + end + defp list_items do Tasks.list_items() end From 5f24e670d3b68966c95522d3380ebdc30f284977 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 26 Oct 2022 13:15:12 +0100 Subject: [PATCH 17/33] Use Hook to send event from client to LiveView When items are sorted (drag and drop finished) send event to the LiveView to update the indexes --- assets/js/app.js | 13 ++++++ lib/app_web/live/item_live/index.ex | 9 ++++- lib/app_web/live/item_live/index.html.heex | 46 +++++++++++++--------- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index bba7e49..121855a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,8 +26,21 @@ import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import Alpine from "../vendor/alpine" + +let Hooks = {} +Hooks.SortList = { + mounted() { + const hook = this + this.el.addEventListener("sortListEvent", e => { + const items = document.querySelectorAll('.draggable') + hook.pushEventTo("#items", "event-items", {bob: true}) + }) + } +} + let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { + hooks: Hooks, dom: { onBeforeElUpdated(from, to) { if (from._x_dataStack) { diff --git a/lib/app_web/live/item_live/index.ex b/lib/app_web/live/item_live/index.ex index f453518..d393860 100644 --- a/lib/app_web/live/item_live/index.ex +++ b/lib/app_web/live/item_live/index.ex @@ -1,6 +1,6 @@ defmodule AppWeb.ItemLive.Index do use AppWeb, :live_view - + alias Phoenix.LiveView.JS alias App.Tasks alias App.Tasks.Item @@ -47,6 +47,13 @@ defmodule AppWeb.ItemLive.Index do {:noreply, push_patch(socket, to: "/")} end + @impl true + def handle_event(event, value, socket) do + IO.inspect(event, label: "new event") + IO.inspect value, label: "value from event" + {:noreply, socket} + end + @impl true def handle_info({:item_created, _item}, socket) do items = list_items() diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index 80c76a1..0856b05 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -20,19 +20,19 @@ <.th>Index - + <%= for item <- @items do %> <.tr id={"item-#{item.id}"} draggable="true" - class="draggable" + class="cursor-grab draggable" x-data="dropdown" x-on:dragstart.self="dragging = true; dragged = $el;" x-on:dragend.self="dragging = false; dragover = false; dragged = null" @dragover="dragover = true; dragElt(dragged, $el)" @dragleave="dragover = false" - @drop="dragover = false" - x-bind:class="dragover ? '!bg-green-100' : (dragging && '!bg-red-100')" + @drop="dragover = false; notifyServer()" + x-bind:class="dragover ? '!bg-green-100' : (dragging && '!bg-red-100'); dragging && 'cursor-grabbing'" > <.td class=""><%= item.text %> <.td><%= item.index %> @@ -49,20 +49,28 @@ /> From b3b4db2708cc43b8ba2e404b9f2568aa5aa8222d Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 26 Oct 2022 15:28:28 +0100 Subject: [PATCH 18/33] Update indexes for items Update indexes and notify clients of new order --- assets/js/app.js | 5 +++-- lib/app/tasks.ex | 15 ++++++++++++--- lib/app_web/live/item_live/index.ex | 6 +++--- lib/app_web/live/item_live/index.html.heex | 7 ++++--- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 121855a..cc3cfd4 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -32,8 +32,9 @@ Hooks.SortList = { mounted() { const hook = this this.el.addEventListener("sortListEvent", e => { - const items = document.querySelectorAll('.draggable') - hook.pushEventTo("#items", "event-items", {bob: true}) + // get list of ids in the new order + const itemIds = [...document.querySelectorAll('.draggable')].map(e => e.dataset.id) + hook.pushEventTo("#items", "sort-items", {itemIds: itemIds}) }) } } diff --git a/lib/app/tasks.ex b/lib/app/tasks.ex index b6d081b..7eb7858 100644 --- a/lib/app/tasks.ex +++ b/lib/app/tasks.ex @@ -10,7 +10,6 @@ defmodule App.Tasks do alias Phoenix.PubSub # PubSub functions - def subscribe() do PubSub.subscribe(App.PubSub, "liveview_items") @@ -22,8 +21,6 @@ defmodule App.Tasks do def notify({:error, reason}, _event), do: {:error, reason} - - @doc """ Returns the list of items. @@ -121,4 +118,16 @@ defmodule App.Tasks do def change_item(%Item{} = item, attrs \\ %{}) do Item.changeset(item, attrs) end + + def update_indexes(item_ids) do + item_ids + |> Enum.with_index(fn id, index -> + item = get_item!(id) + update_item(item, %{index: (index + 1)}) + end) + + + {:ok, item_ids} + |> notify(:item_created) + end end diff --git a/lib/app_web/live/item_live/index.ex b/lib/app_web/live/item_live/index.ex index d393860..f206974 100644 --- a/lib/app_web/live/item_live/index.ex +++ b/lib/app_web/live/item_live/index.ex @@ -48,9 +48,9 @@ defmodule AppWeb.ItemLive.Index do end @impl true - def handle_event(event, value, socket) do - IO.inspect(event, label: "new event") - IO.inspect value, label: "value from event" + def handle_event("sort-items", %{"itemIds" => item_ids}, socket) do + Tasks.update_indexes(item_ids) + {:noreply, socket} end diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index 0856b05..36c6d88 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -24,14 +24,16 @@ <%= for item <- @items do %> <.tr id={"item-#{item.id}"} + data-id={item.id} draggable="true" class="cursor-grab draggable" - x-data="dropdown" + x-data="dragAndDrop" x-on:dragstart.self="dragging = true; dragged = $el;" x-on:dragend.self="dragging = false; dragover = false; dragged = null" @dragover="dragover = true; dragElt(dragged, $el)" @dragleave="dragover = false" @drop="dragover = false; notifyServer()" + @drop.outside="notifyServer()" x-bind:class="dragover ? '!bg-green-100' : (dragging && '!bg-red-100'); dragging && 'cursor-grabbing'" > <.td class=""><%= item.text %> @@ -51,7 +53,7 @@ From ea93452be5184fba692cded9b74acb2ec9a51148 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 26 Oct 2022 22:02:35 +0100 Subject: [PATCH 19/33] Show drag and drop live on all clients - Highlight when an item is currently dragged on all clients - Move items on all clients - Update indexes --- assets/js/app.js | 59 ++++++++++++++++++++++ lib/app/tasks.ex | 21 ++++++-- lib/app_web/live/item_live/index.ex | 42 ++++++++++++++- lib/app_web/live/item_live/index.html.heex | 31 +++--------- 4 files changed, 126 insertions(+), 27 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index cc3cfd4..1c04f0c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -36,6 +36,26 @@ Hooks.SortList = { const itemIds = [...document.querySelectorAll('.draggable')].map(e => e.dataset.id) hook.pushEventTo("#items", "sort-items", {itemIds: itemIds}) }) + + this.el.addEventListener("hightlightItem", e => { + itemId = e.detail.id + hook.pushEventTo("#items", "highlight-item", {itemId: itemId}) + }) + + this.el.addEventListener("removeHighlight", e => { + itemId = e.detail.id + hook.pushEventTo("#items", "remove-highlight", {itemId: itemId}) + }) + + + this.el.addEventListener("dragElt", e => { + idOver = e.detail.idOver + idDragged = e.detail.idDragged + // hook.pushEventTo("#items", "drag-elt", {idOver: idOver, idDragged: idDragged}) + if (idOver != idDragged) { + hook.pushEventTo("#items", "drag-elt", {idOver: idOver, idDragged: idDragged}) + } + }) } } @@ -52,6 +72,45 @@ let liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken} }) +window.addEventListener(`phx:highlight`, (e) => { + document.querySelectorAll(`[data-highlight]`).forEach(el => { + + if(el.id == e.detail.id){ + liveSocket.execJS(el, el.getAttribute("data-highlight")) + } + }) +}) + +window.addEventListener(`phx:remove-highlight`, (e) => { + document.querySelectorAll(`[data-remove-highlight]`).forEach(el => { + + if(el.id == e.detail.id){ + liveSocket.execJS(el, el.getAttribute("data-remove-highlight")) + + } + }) +}) + +window.addEventListener(`phx:drag-and-drop`, (e) => { + overItem = document.querySelector(`#${e.detail.item_id_over}`) + draggedItem = document.querySelector(`#${e.detail.item_id_dragged}`) + const items = document.querySelector('#items') + const listItems = [...document.querySelectorAll(".draggable")] + // + if (listItems.indexOf(draggedItem) < listItems.indexOf(overItem)) { + items.insertBefore(draggedItem, overItem.nextSibling) + } + if (listItems.indexOf(draggedItem) > listItems.indexOf(overItem)) { + items.insertBefore(draggedItem, overItem) + } + // document.querySelectorAll(`[data-hover]`).forEach(el => { + + // if(el.id == e.detail.id){ + // liveSocket.execJS(el, el.getAttribute("data-hover")) + // } + // }) +}) + // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", info => topbar.show()) diff --git a/lib/app/tasks.ex b/lib/app/tasks.ex index 7eb7858..d0338b4 100644 --- a/lib/app/tasks.ex +++ b/lib/app/tasks.ex @@ -31,7 +31,7 @@ defmodule App.Tasks do """ def list_items do - Repo.all(Item) + Repo.all(from i in Item, order_by: i.index) end @doc """ @@ -123,11 +123,26 @@ defmodule App.Tasks do item_ids |> Enum.with_index(fn id, index -> item = get_item!(id) - update_item(item, %{index: (index + 1)}) + update_item(item, %{index: index + 1}) end) - {:ok, item_ids} |> notify(:item_created) end + + def item_selected(item_id) do + PubSub.broadcast(App.PubSub, "liveview_items", {:item_selected, item_id}) + end + + def item_dropped(item_id) do + PubSub.broadcast(App.PubSub, "liveview_items", {:item_dropped, item_id}) + end + + def drag_and_drop(item_id_over, item_id_dragged) do + PubSub.broadcast( + App.PubSub, + "liveview_items", + {:drag_and_drop, {item_id_over, item_id_dragged}} + ) + end end diff --git a/lib/app_web/live/item_live/index.ex b/lib/app_web/live/item_live/index.ex index f206974..d5e2044 100644 --- a/lib/app_web/live/item_live/index.ex +++ b/lib/app_web/live/item_live/index.ex @@ -54,13 +54,53 @@ defmodule AppWeb.ItemLive.Index do {:noreply, socket} end + @impl true + def handle_event("highlight-item", %{"itemId" => item_id}, socket) do + Tasks.item_selected(item_id) + {:noreply, socket} + end + + @impl true + def handle_event("remove-highlight", %{"itemId" => item_id}, socket) do + Tasks.item_dropped(item_id) + {:noreply, socket} + end + + @impl true + def handle_event( + "drag-elt", + %{"idOver" => item_id_over, "idDragged" => item_id_dragged}, + socket + ) do + Tasks.drag_and_drop(item_id_over, item_id_dragged) + {:noreply, socket} + end + @impl true def handle_info({:item_created, _item}, socket) do items = list_items() - # messages = socket.assigns.messages ++ [message] {:noreply, assign(socket, items: items)} end + @impl true + def handle_info({:item_selected, item_id}, socket) do + {:noreply, push_event(socket, "highlight", %{id: item_id})} + end + + @impl true + def handle_info({:item_dropped, item_id}, socket) do + {:noreply, push_event(socket, "remove-highlight", %{id: item_id})} + end + + @impl true + def handle_info({:drag_and_drop, {item_id_over, item_id_dragged}}, socket) do + {:noreply, + push_event(socket, "drag-and-drop", %{ + item_id_over: item_id_over, + item_id_dragged: item_id_dragged + })} + end + defp list_items do Tasks.list_items() end diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index 36c6d88..76e97a9 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -26,15 +26,16 @@ id={"item-#{item.id}"} data-id={item.id} draggable="true" - class="cursor-grab draggable" + class="draggable" x-data="dragAndDrop" - x-on:dragstart.self="dragging = true; dragged = $el;" - x-on:dragend.self="dragging = false; dragover = false; dragged = null" - @dragover="dragover = true; dragElt(dragged, $el)" + x-on:dragstart.self="dragging = true; dragged = $el; $dispatch('hightlightItem', {id: $el.id})" + x-on:dragend.self="dragging = false; dragover = false; dragged = null; $dispatch('sortListEvent'); $dispatch('removeHighlight',{id: $el.id} )" + @dragover.throttle="dragover = true; $dispatch('dragElt', {idOver: $el.id, idDragged: dragged.id})" @dragleave="dragover = false" - @drop="dragover = false; notifyServer()" - @drop.outside="notifyServer()" - x-bind:class="dragover ? '!bg-green-100' : (dragging && '!bg-red-100'); dragging && 'cursor-grabbing'" + @drop="dragover = false" + x-bind:class="dragging ? 'cursor-grabbing' : 'cursor-grab'" + data-highlight={JS.add_class("!bg-yellow-300")} + data-remove-highlight={JS.remove_class("!bg-yellow-300")} > <.td class=""><%= item.text %> <.td><%= item.index %> @@ -51,27 +52,11 @@ /> From 9595d9cf5f6ed5af0a9d9ea7d85c5c7ceb1aa6ae Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sat, 29 Oct 2022 09:50:20 +0100 Subject: [PATCH 20/33] Update tests Update tests to match return value from PubSub --- assets/js/app.js | 6 ----- drag-and-drop.md | 25 ++++++++++++++++++++ lib/app/tasks.ex | 5 ++-- lib/app_web/live/item_live/form_component.ex | 2 +- 4 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 drag-and-drop.md diff --git a/assets/js/app.js b/assets/js/app.js index 1c04f0c..75e5762 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -103,12 +103,6 @@ window.addEventListener(`phx:drag-and-drop`, (e) => { if (listItems.indexOf(draggedItem) > listItems.indexOf(overItem)) { items.insertBefore(draggedItem, overItem) } - // document.querySelectorAll(`[data-hover]`).forEach(el => { - - // if(el.id == e.detail.id){ - // liveSocket.execJS(el, el.getAttribute("data-hover")) - // } - // }) }) // Show progress bar on live navigation and form submits diff --git a/drag-and-drop.md b/drag-and-drop.md new file mode 100644 index 0000000..aa50fcf --- /dev/null +++ b/drag-and-drop.md @@ -0,0 +1,25 @@ +# Drag and drop + +A drag and drop implementation using Alpine.js combine +with Phoenix LiveView to sort items in a list. + + +Let's start by creating a new Phoenix application: + +```sh +mix phx.new . --app app --no-dashboard --no-gettext --no-mailer +``` + +Then we install Tailwind, see https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix +and add Petal Component, this to create the UI without starting from scratch. + +We can use `mix gen.live Tasks Item items text:string index:integer` to let Phoenix +create the structure for the live items' page. + +We can now focus on using the drag and drop html feature. + +Add the draggable attribute + +see: +- https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API +- https://www.youtube.com/watch?v=jfYWwQrtzzY diff --git a/lib/app/tasks.ex b/lib/app/tasks.ex index d0338b4..8dfbfca 100644 --- a/lib/app/tasks.ex +++ b/lib/app/tasks.ex @@ -15,8 +15,9 @@ defmodule App.Tasks do PubSub.subscribe(App.PubSub, "liveview_items") end - def notify({:ok, message}, event) do - PubSub.broadcast(App.PubSub, "liveview_items", {event, message}) + def notify({:ok, item}, event) do + PubSub.broadcast(App.PubSub, "liveview_items", {event, item}) + {:ok, item} end def notify({:error, reason}, _event), do: {:error, reason} diff --git a/lib/app_web/live/item_live/form_component.ex b/lib/app_web/live/item_live/form_component.ex index baea30a..379f721 100644 --- a/lib/app_web/live/item_live/form_component.ex +++ b/lib/app_web/live/item_live/form_component.ex @@ -42,7 +42,7 @@ defmodule AppWeb.ItemLive.FormComponent do defp save_item(socket, :new, item_params) do case Tasks.create_item(item_params) do - :ok -> + {:ok, _item} -> {:noreply, socket |> put_flash(:info, "Item created successfully") From 589450fa9d0774a3fa4e4363c1643e558683557c Mon Sep 17 00:00:00 2001 From: SimonLab Date: Sat, 29 Oct 2022 14:47:54 +0100 Subject: [PATCH 21/33] Add initialisation documentation Create documentatio on how to setup a new Phoenix application using Tailwind, Petal components and Alpine.js --- assets/js/app.js | 2 - drag-and-drop.md | 111 +++++++++++++++++++- lib/app_web/templates/layout/root.html.heex | 2 +- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 75e5762..7640a22 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,8 +24,6 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" -import Alpine from "../vendor/alpine" - let Hooks = {} Hooks.SortList = { diff --git a/drag-and-drop.md b/drag-and-drop.md index aa50fcf..3e96a5a 100644 --- a/drag-and-drop.md +++ b/drag-and-drop.md @@ -4,22 +4,123 @@ A drag and drop implementation using Alpine.js combine with Phoenix LiveView to sort items in a list. +TOC + +version used for this turorial: + +Phoenix: 1.6.14 +LiveView: 0.18 + +## Initialisation + Let's start by creating a new Phoenix application: ```sh -mix phx.new . --app app --no-dashboard --no-gettext --no-mailer +mix phx.new app --no-dashboard --no-gettext --no-mailer +``` +Install the dependencies when asked: + +```sh +Fetch and install dependencies? [Yn] y +``` + +Then follow the last instructions to make sure the Phoenix application +is running correctly: + +```sh +cd app +mix ecto.create +mix phx.server +``` + +You should be able to see [localhost:4000/](localhost:4000/) + +To build the UI we're going to use [Petal Components](https://petal.build/components). +Petal provides the [table](https://petal.build/components/table) components that will +use to display our items. + +Petal is using [Tailwind](https://tailwindcss.com/) and [Alpine.js](https://alpinejs.dev/), +so we first need to install them. Follow the installation steps describe in https://petal.build/components +to install Tailwind and Petal Components. (see also https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix) + + +Petal is using LiveView 0.18. To avoid dependecy conflict you need to update also your +LiveView version to 0.18. In `mix.exs` make sure you have: + +```elixir +{:phoenix_live_view, "~> 0.18"} ``` -Then we install Tailwind, see https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix -and add Petal Component, this to create the UI without starting from scratch. +While we are waiting for Phoenix 1.7 to be avalaible we need to fix +a breaking change linked to LiveView 0.18. +The `live_flash/2` is now part of [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/0.18.3/Phoenix.Component.html#live_flash/2) +module. This function will be deprecated with Phoenix 1.7 but to make +sure our application can run we need to add this module in our `view_helper` function. + +In `app_web.ex` and `import Phoenix.Component` in the `view_helper` function. + +Before running the application we can clean the `lib/app_web/templates/layout/root.html.heex` file: + +```html + + + + + + + + + <%= live_title_tag(assigns[:page_title] || "App", suffix: " · Phoenix Framework") %> + + + + + <%= @inner_content %> + + +``` + +Note that we have added `` +to the `head`. This will add Alpine.js features to our application. +See the [Alpine.js docuementation](https://alpinejs.dev/essentials/installation). + +And the `lib/app_web/templates/page/index.html.heex`: + +```html +<.container> + <.h2 class="text-red-500"> + Hello App! + + +``` + +You can now run `mix deps.get` to make sure all dependencies are installed +and `mix phx.server`! + +There are quiet a few steps to do for this setup. +Hopefully this will be simplified with Phoenix 1.7 coming soon. +Don't hesite to open an issue on this Github repository if +you still think there are some missing information. + + + + + + We can use `mix gen.live Tasks Item items text:string index:integer` to let Phoenix -create the structure for the live items' page. +create the structure for the lve items' page. We can now focus on using the drag and drop html feature. Add the draggable attribute -see: +refs: - https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API - https://www.youtube.com/watch?v=jfYWwQrtzzY diff --git a/lib/app_web/templates/layout/root.html.heex b/lib/app_web/templates/layout/root.html.heex index 072c371..ee299d7 100644 --- a/lib/app_web/templates/layout/root.html.heex +++ b/lib/app_web/templates/layout/root.html.heex @@ -6,6 +6,7 @@ <%= live_title_tag(assigns[:page_title] || "App", suffix: " · Phoenix Framework") %> + + ` to the `head`. This will add Alpine.js features to our application. -See the [Alpine.js docuementation](https://alpinejs.dev/essentials/installation). +See the [Alpine.js documentation](https://alpinejs.dev/essentials/installation). -If you prefer to avoid using the Alpine.js cdn link, you can downaload and save +If you prefer to avoid using the Alpine.js cdn link, you can download and save the content of the Alpine.js from https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js into `assets/vendor/alpine.js` file and import in `app.js` with: @@ -119,8 +120,8 @@ https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.HTMLFormatter.html There are a few steps to do for this setup. Hopefully this will be simplified with Phoenix 1.7 coming soon. -Don't hesite to open an issue on this Github repository if -you still think there are some missing information. +Don't hesitate to open an issue on this Github repository if +you still think there is some missing information. ## Create items @@ -135,7 +136,7 @@ This will create the - `Item` [schema](https://hexdocs.pm/ecto/Ecto.Schema.html) - `items` table with the text and index fields -Templates and live controllers will also be crated automatically. +Templates and live controllers will also be created automatically. To keep the application simple we won't use the `edit` and `delete` endpoints for items. @@ -191,10 +192,10 @@ in `lib/app_web/live/item_live/index.html.heex`: ``` Note that we have added the `title` attribute to the `modal` Petal component. -And we are using the `table` Petal component to dispaly the items. +And we are using the `table` Petal component to display the items. -We also need to update the form modal which create new items. Update the +We also need to update the form modal which creates new items. Update the file in `lib/app_web/live/item_live/form_component.html.heex`: ```heex @@ -242,8 +243,8 @@ def changeset(item, attrs) do end ``` -You should now be able to create new items see them displayed! -However we need to make sure an index value for the created item. +You should now be able to create new items and see them displayed! +However we need to make sure an `index` value is also created for the item. Update the `create_item` function In `lib/app/tasks.ex`: ```elixir @@ -270,12 +271,12 @@ end [PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) is used to send and listen to `messages`. Any clients connected to a `topic` can -listen for new messsages on this topic. +listen for new messages on this topic. In this section we are using PubSub to notify clients when new items are created. -The first step is to connected the client when the LiveView page is requested. -We are going to add helper functions in `libe/app/tasks.ex` to manages the PubSub +The first step is to connect the client when the LiveView page is requested. +We are going to add helper functions in `lib/app/tasks.ex` to manages the PubSub feature, and the first one to add is `subscribe`: ```elixir @@ -297,8 +298,8 @@ def mount(_params, _session, socket) do end ``` -We are checking the socket is properly connected to the client before calling -the new new `subscribe` function. +We are checking if the socket is properly connected to the client before calling +the new `subscribe` function. We are going to write now the `notify` function which uses the @@ -332,7 +333,7 @@ end The `notify` function will send the `:item_created` message to all clients. -Finally we need to listent to this new messages and update our liveview. +Finally we need to listen to this new messages and update our liveview. In `lib/app_web/live/item_live/index.ex`, add: ```elixir @@ -344,7 +345,7 @@ end ``` When the client receive the `:item_created` we are getting the list of items -from the database and assign the list to the socket. This will update the +from the database and assigning the list to the socket. This will update the liveview template with the new created item. @@ -380,7 +381,7 @@ attribute: x-data defines a chunk of HTML as an Alpine component and provides the reactive data for that component to reference. -in `lib/app_web/live/item_live/indx.html.heex`: +in `lib/app_web/live/item_live/index.html.heex`: ```html @@ -418,12 +419,12 @@ events: ``` -When the `dragstart` event is triggered (ie an item is moved) we update the new +When the `dragstart` event is triggered (i.e. an item is moved) we update the newly `selected` value to `true` (this value has been initalised in the `x-data` attribute). -When the `dragend` event is trigger we set `selected` to false. +When the `dragend` event is triggered we set `selected` to false. Finally we are using `x-bind:class` to add css class depending on the value of -`selected`. In this case we have customise the display of the cursor. +`selected`. In this case we have customised the display of the cursor. To make is a bit more obvious which item is currently moved, we want to change the background colour for this item. We also want all connected clients to see @@ -442,7 +443,7 @@ Update the `tr` tag with the following: > ``` -The [dispatch](https://alpinejs.dev/magics/dispatch) Alpine.js function send +The [dispatch](https://alpinejs.dev/magics/dispatch) Alpine.js function sends a new custom js event. We are going to use [hooks](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook) to listen for this event and then notify LiveView. @@ -483,7 +484,7 @@ let liveSocket = new LiveSocket("/live", Socket, { }) ``` -The last step for the hooks to initialised is to add `phx-hook` attrubute +The last step for the hooks to initialised is to add `phx-hook` attribute in our `lib/app_web/live/item_live/index.html.heex`: ```heex @@ -493,7 +494,7 @@ in our `lib/app_web/live/item_live/index.html.heex`: Note that the value of `phx-hook` must be the same as `Hooks.Items = ...` define in `app.js` -We have now the hooks listening to the `hightlight` and `remove-highlight` events, +We now have the hooks listening to the `highlight` and `remove-highlight` events, and we use the [pushEventTo](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook) function to send a message to the LiveView server. @@ -542,7 +543,7 @@ def handle_info({:drop_item, item_id}, socket) do end ``` -The LiveView will send the `hightlight` and `remove-highlight` to the client. +The LiveView will send the `highlight` and `remove-highlight` to the client. The final step is to handle these Phoenix events with [Phoenix.LiveView.JS](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html) to add and remove the background colour via Tailwind css class. @@ -568,7 +569,7 @@ window.addEventListener("phx:remove-highlight", (e) => { ``` For each item we are checking if the id match the id linked to the drag/drop event, -then exectute the Phoenix.LiveView.JS function that we now have to define: +then execute the Phoenix.LiveView.JS function that we now have to define: ```heex <.tr @@ -588,15 +589,16 @@ sure the two functions are accessible in the template. Again there are a few steps to make sure the highlight for the selected item -is properly dispalyed. However all the clients should now be able to see +is properly displayed. However all the clients should now be able to see the drag/drop action! So far we have added the code to be able to drag an item, however we haven't yet implemented the code to sort the items. -We want to switch the positions of the items when the selected item is hover -another item. We are going to use the [dragover](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragover_event) +We want to switch the positions of the items when the selected item is hovering +over another item. +We are going to use the [dragover](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragover_event) event for this: @@ -616,13 +618,13 @@ event for this: > ``` -We have a added `x-data="{selectedItem: null}` to the `tobody` html tag. +We have added `x-data="{selectedItem: null}` to the `tbody` html tag. This value represents which element is currently being moved. Then we have `x-on:dragover.throttle="$dispatch('dragoverItem', {selectedItemId: selectedItem.id, currentItemId: $el.id})"` -the [throttle](https://alpinejs.dev/directives/on#throttle) Alpine.js modifier +The [throttle](https://alpinejs.dev/directives/on#throttle) Alpine.js modifier will only send the event `dragoverItem` once every 250ms max. Similar to how we manage the highlights events, we need to update the `app.js` file and add to the Hooks: @@ -638,7 +640,7 @@ this.el.addEventListener("dragoverItem", e => { }) ``` -We only want to push teh `dragoverItem` event to the server if the item is over +We only want to push the `dragoverItem` event to the server if the item is over an item which is different than itself. @@ -695,11 +697,11 @@ window.addEventListener("phx:dragover-item", (e) => { } }) ``` -We compare the selcted item position in the list with the "over" item +We compare the selected item position in the list with the "over" item and use `insertBefore` js function to add our item at the correct DOM place. -You should now be able to see on differenct clients the selected item +You should now be able to see on different clients the selected item moved into the list during the drag and drop. However we haven't updated the indexes of the items yet. @@ -772,7 +774,7 @@ def handle_info(:indexes_updated, socket) do end ``` -We fetch the list of items from the database and let LiveView updates the UI +We fetch the list of items from the database and let LiveView update the UI automatically. You should now have a complete drag-and-drop feature shared with multiple From 163b5f9a22f2116c370366a48eb5d48de608a28c Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 31 Oct 2022 21:43:28 +0000 Subject: [PATCH 27/33] Final check - remove unused Alpine.js x-daat value - remove console.log - add link in Readme to drag-and-drop.md --- README.md | 28 +--------------------- drag-and-drop.md | 1 - lib/app_web/live/item_live/index.html.heex | 14 ++--------- 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 333d18d..3feb7f7 100644 --- a/README.md +++ b/README.md @@ -96,30 +96,4 @@ Along the nodejs application used for the sotopwatch example, we have created a Phoenix application to test see how drag-and-drop can be implemented using Alpinejs. -```sh -mix phx.new . --app app --no-dashboard --no-gettext --no-mailer -``` - -Then we install Tailwind, see https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix -and add Petal Component, this to create the UI without starting from scratch. - -We can use `mix gen.live Tasks Item items text:string index:integer` to let Phoenix -create the structure for the live items' page. - -We can now focus on using the drag and drop html feature. - -Add the draggable attribute - -see: -- https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API -- https://www.youtube.com/watch?v=jfYWwQrtzzY - - - - - - - - - - +See [drag-and-drop.md](drag-and-drop.md) diff --git a/drag-and-drop.md b/drag-and-drop.md index ad036fd..c348433 100644 --- a/drag-and-drop.md +++ b/drag-and-drop.md @@ -559,7 +559,6 @@ window.addEventListener("phx:highlight", (e) => { }) window.addEventListener("phx:remove-highlight", (e) => { - console.log('yeaa@') document.querySelectorAll("[data-highlight]").forEach(el => { if(el.id == e.detail.id) { liveSocket.execJS(el, el.getAttribute("data-remove-highlight")) diff --git a/lib/app_web/live/item_live/index.html.heex b/lib/app_web/live/item_live/index.html.heex index 89386f7..2b91a9c 100644 --- a/lib/app_web/live/item_live/index.html.heex +++ b/lib/app_web/live/item_live/index.html.heex @@ -27,12 +27,10 @@ data-id={item.id} draggable="true" class="draggable" - x-data="dragAndDrop" + x-data="{dragging: false}" x-on:dragstart.self="dragging = true; dragged = $el; $dispatch('hightlightItem', {id: $el.id})" x-on:dragend.self="dragging = false; dragover = false; dragged = null; $dispatch('sortListEvent'); $dispatch('removeHighlight',{id: $el.id} )" - @dragover.throttle="dragover = true; $dispatch('dragElt', {idOver: $el.id, idDragged: dragged.id})" - @dragleave="dragover = false" - @drop="dragover = false" + @dragover.throttle="$dispatch('dragElt', {idOver: $el.id, idDragged: dragged.id})" x-bind:class="dragging ? 'cursor-grabbing' : 'cursor-grab'" data-highlight={JS.add_class("!bg-yellow-300")} data-remove-highlight={JS.remove_class("!bg-yellow-300")} @@ -51,11 +49,3 @@ label="New Item" /> - From dfcebaa1d46871ba5c5f40ebdb680c7e354eae69 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Tue, 1 Nov 2022 10:51:28 +0000 Subject: [PATCH 28/33] Add screenshots Add images to documentation --- drag-and-drop.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/drag-and-drop.md b/drag-and-drop.md index c348433..bc4098f 100644 --- a/drag-and-drop.md +++ b/drag-and-drop.md @@ -34,7 +34,9 @@ mix ecto.create mix phx.server ``` -You should be able to see [localhost:4000/](localhost:4000/) +You should be able to see [localhost:4000/](http://localhost:4000/): + +![Phoenix App](https://user-images.githubusercontent.com/6057298/199209631-b3c084e0-62f4-43f2-a4bc-ccb57f101443.png) To build the UI we're going to use [Petal Components](https://petal.build/components). Petal provides the [table](https://petal.build/components/table) components that will @@ -371,6 +373,10 @@ let liveSocket = new LiveSocket("/live", Socket, { This is to make sure Alpine.js keeps track of the DOM changes created by LiveView. +See the [Phoenix LiveView JavaScript interoperability documentation](https://hexdocs.pm/phoenix_live_view/js-interop.html): + +![Alpine.js](https://user-images.githubusercontent.com/6057298/199215481-489e71fb-9a95-4d24-9484-e90b6257211c.png) + Now we're going to start by adding a new background colour to the item being dragged and remove the colour when the drag ends. @@ -766,7 +772,7 @@ if you think there is a better way to do this don't hesitate to open an issue, t Finally similar to the way we tell clients a new item has been created, we broadcast a new message, `indexes_updated`: -```elxir +```elixir def handle_info(:indexes_updated, socket) do items = list_items() {:noreply, assign(socket, items: items)} From ee7c2ceb6c19ed89ed449727c47a72db68df4c14 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Tue, 1 Nov 2022 15:34:37 +0000 Subject: [PATCH 29/33] Simplify Phoenix app setup Remove Tailwind and Petal Components to make the setup easier. --- drag-and-drop.md | 177 ++++++++++++++++------------------------------- 1 file changed, 60 insertions(+), 117 deletions(-) diff --git a/drag-and-drop.md b/drag-and-drop.md index bc4098f..e64abfd 100644 --- a/drag-and-drop.md +++ b/drag-and-drop.md @@ -9,7 +9,7 @@ to the Phoenix LiveView application. versions used: - Phoenix: 1.6.15 -- LiveView: 0.18 +- LiveView: 0.17.12 - Alpine.js: 3.x.x ## Initialisation @@ -38,31 +38,13 @@ You should be able to see [localhost:4000/](http://localhost:4000/): ![Phoenix App](https://user-images.githubusercontent.com/6057298/199209631-b3c084e0-62f4-43f2-a4bc-ccb57f101443.png) -To build the UI we're going to use [Petal Components](https://petal.build/components). -Petal provides the [table](https://petal.build/components/table) components that will -be used to display our items. -Petal is using [Tailwind](https://tailwindcss.com/) and [Alpine.js](https://alpinejs.dev/), -so we first need to install them. Follow the installation steps described in https://petal.build/components -to install Tailwind and Petal Components. (see also https://github.com/dwyl/learn-tailwind#part-2-tailwind-in-phoenix) +We can now update the generated html +in `lib/app_web/templates/layout/root.html.heex` file: - -Petal is using LiveView 0.18. To avoid dependencies conflict you also need to update your -LiveView version to 0.18. In `mix.exs` make sure you have: - -```elixir -{:phoenix_live_view, "~> 0.18"} -``` - -While we are waiting for Phoenix 1.7 to be available we need to fix -a breaking change linked to LiveView 0.18. -The `live_flash/2` is now part of the [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/0.18.3/Phoenix.Component.html#live_flash/2) -module. This function will be deprecated with Phoenix 1.7 but to make -sure our application can run we need to add this module in our `view_helper` function. - -In `app_web.ex` and `import Phoenix.Component` in the `view_helper` function. - -Before running the application we can clean the `lib/app_web/templates/layout/root.html.heex` file: +- Add Alpine.js CDN script tag, see [Alpine.js documentation](https://alpinejs.dev/essentials/installation) + `` +- Remove the `header` tag containing the Phoenix logo: ```html @@ -89,79 +71,48 @@ Before running the application we can clean the `lib/app_web/templates/layout/ro ``` -Note that we have added `` -to the `head`. This will add Alpine.js features to our application. -See the [Alpine.js documentation](https://alpinejs.dev/essentials/installation). - -If you prefer to avoid using the Alpine.js cdn link, you can download and save -the content of the Alpine.js from https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js -into `assets/vendor/alpine.js` file and import in `app.js` with: - -```js -import Alpine from "../vendor/alpine" -``` - - - -And the `lib/app_web/templates/page/index.html.heex`: - -```html -<.container> - <.h2 class="text-red-500"> - Hello App! - - -``` - You can now run `mix deps.get` to make sure all dependencies are installed and `mix phx.server`! -If you'd like to have the formatter working for the `.heex` -templates, you can update the `.formatter.exs` as describe in -https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.HTMLFormatter.html - -There are a few steps to do for this setup. -Hopefully this will be simplified with Phoenix 1.7 coming soon. -Don't hesitate to open an issue on this Github repository if -you still think there is some missing information. - - ## Create items We can use the [mix phx.gen.live](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Live.html) -command: +command to let Phoenix create the LiveView structure: -run `mix phx.gen.live Tasks Item items text:string index:integer` +```sh +mix phx.gen.live Tasks Item items text:string index:integer` +``` This will create the -- `Tasks` [context](https://hexdocs.pm/phoenix/contexts.html) -- `Item` [schema](https://hexdocs.pm/ecto/Ecto.Schema.html) +- [Tasks context](https://hexdocs.pm/phoenix/contexts.html) +- [Item schema](https://hexdocs.pm/ecto/Ecto.Schema.html) - `items` table with the text and index fields -Templates and live controllers will also be created automatically. -To keep the application simple we won't use the `edit` -and `delete` endpoints for items. +heex Template files and liveView controllers will also be created. -Update `lib/app_web/router.ex` with: +Update `lib/app_web/router.ex` to add the new endpoints: ```elixir scope "/", AppWeb do pipe_through :browser live "/", ItemLive.Index, :index live "/items/new", ItemLive.Index, :new + live "/items/:id/edit", ItemLive.Index, :edit + + live "/items/:id", ItemLive.Show, :show + live "/items/:id/show/edit", ItemLive.Show, :edit end ``` -Update the created items templates to use -Petal components: -in `lib/app_web/live/item_live/index.html.heex`: +in `lib/app_web/live/item_live/index.html.heex`, remove the `edit` and `delete` +links as we won't use them to keep the application simple: ```heex -<.h1 class="text-lg">Listing Items +

Listing Items

<%= if @live_action in [:new, :edit] do %> - <.modal return_to={Routes.item_index_path(@socket, :index)} title="New Item"> + <.modal return_to={Routes.item_index_path(@socket, :index)}> <.live_component module={AppWeb.ItemLive.FormComponent} id={@item.id || :new} @@ -173,35 +124,33 @@ in `lib/app_web/live/item_live/index.html.heex`: <% end %> -<.table> + - <.tr> - <.th>Text - <.th>Index - + + + + <%= for item <- @items do %> - <.tr id={"item-#{item.id}"}> - <.td><%= item.text %> - <.td><%= item.index %> - + + + + <% end %> - +
TextIndex
<%= item.text %><%= item.index %>
-<.button class="mt-3" link_type="live_patch" to={Routes.item_index_path(@socket, :new)} label="New Item"/> +<%= live_patch "New Item", to: Routes.item_index_path(@socket, :new) %> ``` - -Note that we have added the `title` attribute to the `modal` Petal component. -And we are using the `table` Petal component to display the items. - - -We also need to update the form modal which creates new items. Update the -file in `lib/app_web/live/item_live/form_component.html.heex`: +Then in `lib/app_web/live/item_live/form_component.html.heex` remove the +`label`, `number_input` and `error_tag` linked to the `index` as we want our +server to set this value when the item is created: ```heex
+

<%= @title %>

+ <.form let={f} for={@changeset} @@ -209,45 +158,32 @@ file in `lib/app_web/live/item_live/form_component.html.heex`: phx-target={@myself} phx-change="validate" phx-submit="save"> - - <.form_field type="text_input" form={f} field={:text} placeholder="item" /> - - <.button label="Save" phx_disable_with="Saving..." /> + + <%= label f, :text %> + <%= text_input f, :text %> + <%= error_tag f, :text %> + +
+ <%= submit "Save", phx_disable_with: "Saving..." %> +
``` -Now that our UI is fixed, we can focus on managing the events sent to the -liveView. - -Let's first handle the event sent when the modal is closed. -The [Petal modal](https://petal.build/components/modals) sends the `close_modal` -event. Add the following function in `lib/app_web/live/item_live/index.ex` - -```elixir -@impl true -def handle_event("close_modal", _, socket) do - # Go back to the :index live action - {:noreply, push_patch(socket, to: "/")} -end -``` - Then we need to update our `Item` schema to be able to save a new item. -Because we have removed from the modal form the `index` field, we also -want to remove the `validate_required` check for this field on the changeset. +We want to remove the `:index` value from the `validate_required` function in the changeset. Update `lib/app/tasks/item.ex`: ```elixir def changeset(item, attrs) do item |> cast(attrs, [:text, :index]) - |> validate_required([:text]) + |> validate_required([:text]) # index is removed end ``` - -You should now be able to create new items and see them displayed! -However we need to make sure an `index` value is also created for the item. -Update the `create_item` function In `lib/app/tasks.ex`: +Let's update the `create_item` function in `lib/app/tasks.ex` to make +sure Phoenix set the `index` value. +The item's index is equal to the number of existing items + 1: ```elixir def create_item(attrs \\ %{}) do @@ -259,9 +195,8 @@ def create_item(attrs \\ %{}) do |> Repo.insert() end ``` -We make sure the item's index is equal to the number of existing items + 1. -Then we want to update the `list_items` function in the same file to get the +Finally we want to update the `list_items` function in the same file to get the items order by their indexes: ```elixir @@ -269,6 +204,14 @@ def list_items do Repo.all(from i in Item, order_by: i.index) end ``` + +Running the application, you should see a UI similar to: + +![create-items](https://user-images.githubusercontent.com/6057298/199272881-0581b3f8-1e15-408b-9711-05747714a92a.png) +![list-items](https://user-images.githubusercontent.com/6057298/199272939-1343c915-df0b-4b52-a003-47d047e2c6a3.png) + + + ### PubSub [PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) is used From 5bd6e182dd7ff6eee2d30552c680aef7e6325992 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Tue, 1 Nov 2022 20:56:02 +0000 Subject: [PATCH 30/33] Simplify documentation Read again the doc and make it easier to follow/implement --- assets/js/app.js | 49 ------------------ drag-and-drop.md | 131 +++++++++++++++++++++++++++++------------------ 2 files changed, 82 insertions(+), 98 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 7640a22..dcbba6f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -117,52 +117,3 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket - -// Drag and Drop JS version: - -// Select all the items that are draggable -// and the list of items where we can move an item to. -// -// const draggables = document.querySelectorAll(".draggable"); -// const listItems = document.querySelector("#items"); -// -// draggables.forEach(dragable => { -// dragable.addEventListener('dragstart', () => { -// dragable.classList.add('bg-red-100', 'dragging') -// }); -// -// dragable.addEventListener('dragend', () => { -// dragable.classList.remove('bg-red-100', 'dragging') -// }); -// }) -// -// listItems.addEventListener('dragover', e => { -// e.preventDefault() -// const dragged = document.querySelector('.dragging') -// const overItem = getOverItem(e.clientY) -// const moving = direction(dragged, overItem) -// if (moving == "down") { -// listItems.insertBefore(dragged, overItem.nextSibling) -// } -// -// if (moving == "up"){ -// listItems.insertBefore(dragged, overItem) -// } -// }) -// -// function getOverItem(y) { -// const draggables = [...document.querySelectorAll(".draggable")] -// return draggables.find( item => { -// const box = item.getBoundingClientRect() -// return y > box.top && y < box.bottom -// }) -// } -// -// function direction(dragged, overItem) { -// const draggables = [...document.querySelectorAll(".draggable")] -// if (draggables.indexOf(dragged) < draggables.indexOf(overItem)) { -// return "down" -// } else { -// return "up" -// } -// } diff --git a/drag-and-drop.md b/drag-and-drop.md index e64abfd..ad09ec3 100644 --- a/drag-and-drop.md +++ b/drag-and-drop.md @@ -210,9 +210,7 @@ Running the application, you should see a UI similar to: ![create-items](https://user-images.githubusercontent.com/6057298/199272881-0581b3f8-1e15-408b-9711-05747714a92a.png) ![list-items](https://user-images.githubusercontent.com/6057298/199272939-1343c915-df0b-4b52-a003-47d047e2c6a3.png) - - -### PubSub +## Make it real time [PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) is used to send and listen to `messages`. Any clients connected to a `topic` can @@ -320,8 +318,24 @@ See the [Phoenix LiveView JavaScript interoperability documentation](https://hex ![Alpine.js](https://user-images.githubusercontent.com/6057298/199215481-489e71fb-9a95-4d24-9484-e90b6257211c.png) -Now we're going to start by adding a new background colour to the item being -dragged and remove the colour when the drag ends. +Add the following content at the end of the `assets/css/app.css` file: + +```css +.cursor-grab{ + cursor: grab; +} + +.cursor-grabbing{ + cursor: grabbing; +} + +.bg-yellow-300{ + background-color: rgb(253 224 71); +} +``` + +These css classes will be used to make our items a bit more visible when moved. + We are going to define an Alpine component using the [x-data](https://alpinejs.dev/directives/data) attribute: @@ -332,13 +346,13 @@ provides the reactive data for that component to reference. in `lib/app_web/live/item_live/index.html.heex`: -```html - +```heex + <%= for item <- @items do %> - <.tr id={"item-#{item.id}"} x-data="{}" draggable="true"> - <.td><%= item.text %> - <.td><%= item.index %> - + + <%= item.text %> + <%= item.index %> + <% end %> ``` @@ -352,37 +366,41 @@ and [dragend](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drage events: -```html - +```heex + <%= for item <- @items do %> - <.tr id={"item-#{item.id}"} - draggable="true"> - x-data="{selected: false}" - x-on:dragstart="selected = true" - x-on:dragend="selected = false" - x-bind:class="selected ? 'cursor-grabbing' : 'cursor-grab'" - <.td><%= item.text %> - <.td><%= item.index %> - + + <%= item.text %> + <%= item.index %> + <% end %> ``` When the `dragstart` event is triggered (i.e. an item is moved) we update the newly -`selected` value to `true` (this value has been initalised in the `x-data` attribute). +`selected` value define in `x-data` to `true`. When the `dragend` event is triggered we set `selected` to false. Finally we are using `x-bind:class` to add css class depending on the value of `selected`. In this case we have customised the display of the cursor. -To make is a bit more obvious which item is currently moved, we want to change -the background colour for this item. We also want all connected clients to see -the new background colour. +To make the moved item a bit more obvious, we also change +the background colour. + +In this step we also make sure that all connected clients can see +the new background colour of the moved item! Update the `tr` tag with the following: ```html -<.tr + id}, socket) do Tasks.drop_item(id) {:noreply, socket} -end @impl true +end ``` The `Tasks` functions `drag_item` and `drop_item` are using PubSub to send @@ -496,7 +515,7 @@ The LiveView will send the `highlight` and `remove-highlight` to the client. The final step is to handle these Phoenix events with [Phoenix.LiveView.JS](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html) to add and remove the background colour via Tailwind css class. -In `assets/js/app.js` add the event listeners: +In `assets/js/app.js` add (for example above `liveSocket.connect()`)the event listeners: ```javascript window.addEventListener("phx:highlight", (e) => { @@ -517,23 +536,24 @@ window.addEventListener("phx:remove-highlight", (e) => { ``` For each item we are checking if the id match the id linked to the drag/drop event, -then execute the Phoenix.LiveView.JS function that we now have to define: +then execute the Phoenix.LiveView.JS function that we now have to define back to our +`lib/app_web/live/item_live/index.html.heex` file. ```heex -<.tr + ``` -Note the call to `add_class` and `remove_class`. You might need to add -`alias Phoenix.LiveView.JS` in `lib/app_web/live/item_live/index.ex` to make -sure the two functions are accessible in the template. +To the call to `add_class` and `remove_class`, you need to add +`alias Phoenix.LiveView.JS` at the top of the file `lib/app_web/live/item_live/index.ex` +This alias will make sure the two functions are accessible in the liveView template. Again there are a few steps to make sure the highlight for the selected item @@ -553,22 +573,26 @@ event for this: ```heex <%= for item <- @items do %> - <.tr + ``` We have added `x-data="{selectedItem: null}` to the `tbody` html tag. This value represents which element is currently being moved. +We have also added the `class="item"`. This will be used later on in `app.js` +to get the list of items using `querySelectorAll`. + Then we have `x-on:dragover.throttle="$dispatch('dragoverItem', {selectedItemId: selectedItem.id, currentItemId: $el.id})"` @@ -607,7 +631,10 @@ def handle_event( Tasks.dragover_item(current_item_id, selected_item_id) {:noreply, socket} end +``` +and +```elixir @impl true def handle_info({:dragover_item, {current_item_id, selected_item_id}}, socket) do {:noreply, @@ -656,24 +683,25 @@ indexes of the items yet. We want to send a new event when the `dragend` is emitted: ```heex -<.tr + ``` -We have added the `data-id` attribute to store the item's id. +We have added the `data-id` attribute to store the item's id and created the +`$dispatch('update-indexes')` event. -In `app.js` we listen to the event: +In `app.js` we listen to the event in the Hook: ```javascript this.el.addEventListener("update-indexes", e => { @@ -688,8 +716,9 @@ event `updateIndexes` In `lib/app_web/live/item_live/index.ex` we add a new `handle_event` ```elixir +@impl true def handle_event("updateIndexes", %{"ids" => ids}, socket) do -( Tasks.update_items_index(ids) + Tasks.update_items_index(ids) {:noreply, socket} end ``` @@ -716,6 +745,7 @@ Finally similar to the way we tell clients a new item has been created, we broadcast a new message, `indexes_updated`: ```elixir +@impl true def handle_info(:indexes_updated, socket) do items = list_items() {:noreply, assign(socket, items: items)} @@ -727,3 +757,6 @@ automatically. You should now have a complete drag-and-drop feature shared with multiple clients! + +Thanks for reading and again don't hesitate to open issues for questions, +enhancement, bug fixes... From 1144e64d389ae63db241a1d84973a25313865520 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 2 Nov 2022 10:35:26 +0000 Subject: [PATCH 31/33] Update code to match the one in drag-and-drop.md Update Phoenix app code to match the one describe in the markdown documentation --- assets/css/app.css | 79 ++++++++---- assets/js/app.js | 118 +++++++++--------- assets/tailwind.config.js | 33 ----- assets/vendor/alpine.js | 5 - config/config.exs | 11 -- config/dev.exs | 3 +- drag-and-drop.md | 2 +- lib/app/tasks.ex | 35 +++--- lib/app_web.ex | 1 - .../live/item_live/form_component.html.heex | 13 +- lib/app_web/live/item_live/index.ex | 58 ++++----- lib/app_web/live/item_live/index.html.heex | 50 ++++---- lib/app_web/live/live_helpers.ex | 69 +++++----- lib/app_web/templates/layout/live.html.heex | 20 +-- lib/app_web/templates/layout/root.html.heex | 3 +- lib/app_web/templates/page/index.html.heex | 10 +- mix.exs | 8 +- mix.lock | 16 ++- 18 files changed, 245 insertions(+), 289 deletions(-) delete mode 100644 assets/tailwind.config.js delete mode 100644 assets/vendor/alpine.js diff --git a/assets/css/app.css b/assets/css/app.css index 3e43892..f2098fa 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,8 +1,5 @@ -@import "tailwindcss/base"; -@import "tailwindcss/components"; -@import "tailwindcss/utilities"; - /* This file is for your main application CSS */ +@import "./phoenix.css"; /* Alerts and form errors used by phx.new */ .alert { @@ -53,47 +50,83 @@ cursor: wait; } - -.select-wrapper select { - @apply text-sm border-gray-300 rounded-md shadow-sm disabled:bg-gray-100 disabled:cursor-not-allowed focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:focus:border-primary-500 dark:bg-gray-800 dark:text-gray-300 focus:outline-none ; -} - -label.has-error:not(.phx-no-feedback) { - @apply !text-red-900 dark:!text-red-200; +.phx-modal { + opacity: 1!important; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); } -textarea.has-error:not(.phx-no-feedback), input.has-error:not(.phx-no-feedback), select.has-error:not(.phx-no-feedback) { - @apply !border-red-500 focus:!border-red-500 !text-red-900 !placeholder-red-700 !bg-red-50 dark:!text-red-100 dark:!placeholder-red-300 dark:!bg-red-900 focus:!ring-red-500; +.phx-modal-content { + background-color: #fefefe; + margin: 15vh auto; + padding: 20px; + border: 1px solid #888; + width: 80%; } -input[type=file_input].has-error:not(.phx-no-feedback) { - @apply !border-red-500 !rounded-md focus:!border-red-500 !text-red-900 !placeholder-red-700 !bg-red-50 file:!border-none dark:!border-none dark:!bg-[#160B0B] dark:text-red-400; +.phx-modal-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; } -input[type=checkbox].has-error:not(.phx-no-feedback) { - @apply !border-red-500 !text-red-900 dark:!text-red-200; +.phx-modal-close:hover, +.phx-modal-close:focus { + color: black; + text-decoration: none; + cursor: pointer; } -input[type=radio].has-error:not(.phx-no-feedback) { - @apply !border-red-500; +.fade-in-scale { + animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; } -/* Modal animation */ -.animate-fade-in-scale { - animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; +.fade-out-scale { + animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; } -.animate-fade-in { +.fade-in { animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; } +.fade-out { + animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; +} @keyframes fade-in-scale-keys{ 0% { scale: 0.95; opacity: 0; } 100% { scale: 1.0; opacity: 1; } } +@keyframes fade-out-scale-keys{ + 0% { scale: 1.0; opacity: 1; } + 100% { scale: 0.95; opacity: 0; } +} + @keyframes fade-in-keys{ 0% { opacity: 0; } 100% { opacity: 1; } } +@keyframes fade-out-keys{ + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.cursor-grab{ + cursor: grab; +} + +.cursor-grabbing{ + cursor: grabbing; +} + +.bg-yellow-300{ + background-color: rgb(253 224 71); +} diff --git a/assets/js/app.js b/assets/js/app.js index dcbba6f..a1f4390 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,7 +1,8 @@ // We import the CSS which is extracted to its own file by esbuild. // Remove this line if you add a your own CSS build pipeline (e.g postcss). +import "../css/app.css" -// If you want to use Phoenix channels, run `mix help phx.gen.channeItem 2l` +// If you want to use Phoenix channels, run `mix help phx.gen.channel` // to get started and then uncomment the line below. // import "./user_socket.js" @@ -25,88 +26,86 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" -let Hooks = {} -Hooks.SortList = { +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + +let Hooks = {}; +Hooks.Items = { mounted() { const hook = this - this.el.addEventListener("sortListEvent", e => { - // get list of ids in the new order - const itemIds = [...document.querySelectorAll('.draggable')].map(e => e.dataset.id) - hook.pushEventTo("#items", "sort-items", {itemIds: itemIds}) + + this.el.addEventListener("highlight", e => { + hook.pushEventTo("#items", "highlight", {id: e.detail.id}) }) - this.el.addEventListener("hightlightItem", e => { - itemId = e.detail.id - hook.pushEventTo("#items", "highlight-item", {itemId: itemId}) + this.el.addEventListener("remove-highlight", e => { + hook.pushEventTo("#items", "remove-highlight", {id: e.detail.id}) }) - - this.el.addEventListener("removeHighlight", e => { - itemId = e.detail.id - hook.pushEventTo("#items", "remove-highlight", {itemId: itemId}) + + this.el.addEventListener("dragoverItem", e => { + const currentItemId = e.detail.currentItemId + const selectedItemId = e.detail.selectedItemId + if( currentItemId != selectedItemId) { + hook.pushEventTo("#items", "dragoverItem", {currentItemId: currentItemId, selectedItemId: selectedItemId}) + } }) - - - this.el.addEventListener("dragElt", e => { - idOver = e.detail.idOver - idDragged = e.detail.idDragged - // hook.pushEventTo("#items", "drag-elt", {idOver: idOver, idDragged: idDragged}) - if (idOver != idDragged) { - hook.pushEventTo("#items", "drag-elt", {idOver: idOver, idDragged: idDragged}) - } + + this.el.addEventListener("update-indexes", e => { + console.log('yyyyy') + const ids = [...document.querySelectorAll(".item")].map( i => i.dataset.id) + hook.pushEventTo("#items", "updateIndexes", {ids: ids}) }) } } - -let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, - dom: { - onBeforeElUpdated(from, to) { - if (from._x_dataStack) { - window.Alpine.clone(from, to) - } - } + dom:{ + onBeforeElUpdated(from, to) { + if (from._x_dataStack) { + window.Alpine.clone(from, to) + } + } }, - params: {_csrf_token: csrfToken} + params: {_csrf_token: csrfToken} }) -window.addEventListener(`phx:highlight`, (e) => { - document.querySelectorAll(`[data-highlight]`).forEach(el => { +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", info => topbar.show()) +window.addEventListener("phx:page-loading-stop", info => topbar.hide()) - if(el.id == e.detail.id){ - liveSocket.execJS(el, el.getAttribute("data-highlight")) +window.addEventListener("phx:highlight", (e) => { + document.querySelectorAll("[data-highlight]").forEach(el => { + if(el.id == e.detail.id) { + liveSocket.execJS(el, el.getAttribute("data-highlight")) } }) }) -window.addEventListener(`phx:remove-highlight`, (e) => { - document.querySelectorAll(`[data-remove-highlight]`).forEach(el => { - - if(el.id == e.detail.id){ - liveSocket.execJS(el, el.getAttribute("data-remove-highlight")) - +window.addEventListener("phx:remove-highlight", (e) => { + document.querySelectorAll("[data-highlight]").forEach(el => { + if(el.id == e.detail.id) { + liveSocket.execJS(el, el.getAttribute("data-remove-highlight")) } }) }) -window.addEventListener(`phx:drag-and-drop`, (e) => { - overItem = document.querySelector(`#${e.detail.item_id_over}`) - draggedItem = document.querySelector(`#${e.detail.item_id_dragged}`) - const items = document.querySelector('#items') - const listItems = [...document.querySelectorAll(".draggable")] - // - if (listItems.indexOf(draggedItem) < listItems.indexOf(overItem)) { - items.insertBefore(draggedItem, overItem.nextSibling) - } - if (listItems.indexOf(draggedItem) > listItems.indexOf(overItem)) { - items.insertBefore(draggedItem, overItem) - } -}) +window.addEventListener("phx:dragover-item", (e) => { + const selectedItem = document.querySelector(`#${e.detail.selected_item_id}`) + const currentItem = document.querySelector(`#${e.detail.current_item_id}`) -// Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", info => topbar.show()) -window.addEventListener("phx:page-loading-stop", info => topbar.hide()) + const items = document.querySelector('#items') + const listItems = [...document.querySelectorAll('.item')] + + console.log(selectedItem, currentItem) + + if(listItems.indexOf(selectedItem) < listItems.indexOf(currentItem)){ + items.insertBefore(selectedItem, currentItem.nextSibling) + } + + if(listItems.indexOf(selectedItem) > listItems.indexOf(currentItem)){ + items.insertBefore(selectedItem, currentItem) + } +}) // connect if there are any LiveViews on the page liveSocket.connect() @@ -116,4 +115,3 @@ liveSocket.connect() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket - diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js deleted file mode 100644 index d135f69..0000000 --- a/assets/tailwind.config.js +++ /dev/null @@ -1,33 +0,0 @@ -// See the Tailwind configuration guide for advanced usage -// https://tailwindcss.com/docs/configuration -const colors = require("tailwindcss/colors"); -let plugin = require('tailwindcss/plugin') - -module.exports = { - content: [ - "../lib/*_web.ex", - "../lib/*_web/**/*.*ex", - "./js/**/*.js", - "../deps/petal_components/**/*.*ex", - ], - darkMode: "class", - theme: { - extend: { - colors: { - primary: colors.blue, - secondary: colors.pink, - }, - }, - }, - plugins: [require("@tailwindcss/forms"), - - plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), - plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), - plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), - plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) - ], -}; - - - - diff --git a/assets/vendor/alpine.js b/assets/vendor/alpine.js deleted file mode 100644 index d213dfc..0000000 --- a/assets/vendor/alpine.js +++ /dev/null @@ -1,5 +0,0 @@ -(()=>{var We=!1,Ge=!1,B=[];function $t(e){an(e)}function an(e){B.includes(e)||B.push(e),cn()}function he(e){let t=B.indexOf(e);t!==-1&&B.splice(t,1)}function cn(){!Ge&&!We&&(We=!0,queueMicrotask(ln))}function ln(){We=!1,Ge=!0;for(let e=0;ee.effect(t,{scheduler:r=>{Je?$t(r):r()}}),Ye=e.raw}function Ze(e){K=e}function Ft(e){let t=()=>{};return[n=>{let i=K(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),Y(i))},i},()=>{t()}]}var Bt=[],Kt=[],zt=[];function Vt(e){zt.push(e)}function _e(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Kt.push(t))}function Ht(e){Bt.push(e)}function qt(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Qe(e,t){!e._x_attributeCleanups||Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var et=new MutationObserver(Xe),tt=!1;function rt(){et.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),tt=!0}function fn(){un(),et.disconnect(),tt=!1}var te=[],nt=!1;function un(){te=te.concat(et.takeRecords()),te.length&&!nt&&(nt=!0,queueMicrotask(()=>{dn(),nt=!1}))}function dn(){Xe(te),te.length=0}function m(e){if(!tt)return e();fn();let t=e();return rt(),t}var it=!1,ge=[];function Ut(){it=!0}function Wt(){it=!1,Xe(ge),ge=[]}function Xe(e){if(it){ge=ge.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{Qe(s,o)}),n.forEach((o,s)=>{Bt.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(Kt.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||!o.isConnected||(delete o._x_ignoreSelf,delete o._x_ignore,zt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function xe(e){return D(k(e))}function C(e,t,r){return e._x_dataStack=[t,...k(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function ot(e,t){let r=e._x_dataStack[0];Object.entries(t).forEach(([n,i])=>{r[n]=i})}function k(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?k(e.host):e.parentNode?k(e.parentNode):[]}function D(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,c=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...c,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function ye(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function be(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>pn(n,i),s=>st(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function pn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function st(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),st(e[t[0]],t.slice(1),r)}}var Gt={};function x(e,t){Gt[e]=t}function re(e,t){return Object.entries(Gt).forEach(([r,n])=>{Object.defineProperty(e,`$${r}`,{get(){let[i,o]=at(t);return i={interceptor:be,...i},_e(t,o),n(t,i)},enumerable:!1})}),e}function Yt(e,t,r,...n){try{return r(...n)}catch(i){J(i,e,t)}}function J(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} - -${r?'Expression: "'+r+`" - -`:""}`,t),setTimeout(()=>{throw e},0)}var ve=!0;function Jt(e){let t=ve;ve=!1,e(),ve=t}function P(e,t,r={}){let n;return g(e,t)(i=>n=i,r),n}function g(...e){return Zt(...e)}var Zt=ct;function Qt(e){Zt=e}function ct(e,t){let r={};re(r,e);let n=[r,...k(e)];if(typeof t=="function")return mn(n,t);let i=hn(n,t,e);return Yt.bind(null,e,t,i)}function mn(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(D([n,...e]),i);we(r,o)}}var lt={};function _n(e,t){if(lt[e])return lt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e)||/^(let|const)\s/.test(e)?`(() => { ${e} })()`:e,o=(()=>{try{return new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`)}catch(s){return J(s,t,e),Promise.resolve()}})();return lt[e]=o,o}function hn(e,t,r){let n=_n(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=D([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>J(l,r,t));n.finished?(we(i,n.result,a,s,r),n.result=void 0):c.then(l=>{we(i,l,a,s,r)}).catch(l=>J(l,r,t)).finally(()=>n.result=void 0)}}}function we(e,t,r,n,i){if(ve&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>we(e,s,r,n)).catch(s=>J(s,i,t)):e(o)}else e(t)}var ut="x-";function E(e=""){return ut+e}function Xt(e){ut=e}var er={};function d(e,t){er[e]=t}function ne(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=ft(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(tr((o,s)=>n[o]=s)).filter(rr).map(xn(n,r)).sort(yn).map(o=>gn(e,o))}function ft(e){return Array.from(e).map(tr()).filter(t=>!rr(t))}var dt=!1,ie=new Map,nr=Symbol();function ir(e){dt=!0;let t=Symbol();nr=t,ie.set(t,[]);let r=()=>{for(;ie.get(t).length;)ie.get(t).shift()();ie.delete(t)},n=()=>{dt=!1,r()};e(r),n()}function at(e){let t=[],r=a=>t.push(a),[n,i]=Ft(e);return t.push(i),[{Alpine:I,effect:n,cleanup:r,evaluateLater:g.bind(g,e),evaluate:P.bind(P,e)},()=>t.forEach(a=>a())]}function gn(e,t){let r=()=>{},n=er[t.type]||r,[i,o]=at(e);qt(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),dt?ie.get(nr).push(n):n())};return s.runCleanups=o,s}var Ee=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Se=e=>e;function tr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=or.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var or=[];function Z(e){or.push(e)}function rr({name:e}){return sr().test(e)}var sr=()=>new RegExp(`^${ut}([^:^.]+)\\b`);function xn(e,t){return({name:r,value:n})=>{let i=r.match(sr()),o=r.match(/:([a-zA-Z0-9\-:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var pt="DEFAULT",Ae=["ignore","ref","data","id","tabs","radio","switch","disclosure","bind","init","for","mask","model","modelable","transition","show","if",pt,"teleport"];function yn(e,t){let r=Ae.indexOf(e.type)===-1?pt:e.type,n=Ae.indexOf(t.type)===-1?pt:t.type;return Ae.indexOf(r)-Ae.indexOf(n)}function z(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}var mt=[],ht=!1;function Te(e=()=>{}){return queueMicrotask(()=>{ht||setTimeout(()=>{Oe()})}),new Promise(t=>{mt.push(()=>{e(),t()})})}function Oe(){for(ht=!1;mt.length;)mt.shift()()}function ar(){ht=!0}function R(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>R(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)R(n,t,!1),n=n.nextElementSibling}function O(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}function lr(){document.body||O("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + <%= live_title_tag(assigns[:page_title] || "App", suffix: " · Phoenix Framework") %>