├── .credo.exs ├── .formatter.exs ├── .gitignore ├── .tool-versions ├── .vscode └── tasks.json ├── README.md ├── assets ├── css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ ├── infinity-scroll.js │ └── ping-pong-hook.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── meow.ex ├── meow │ ├── application.ex │ ├── ecto_helper.ex │ ├── meerkats.ex │ ├── meerkats │ │ └── meerkat.ex │ └── repo.ex ├── meow_web.ex └── meow_web │ ├── endpoint.ex │ ├── forms │ ├── filter_form.ex │ ├── pagination_form.ex │ └── sorting_form.ex │ ├── live │ ├── filter_component.ex │ ├── infinity_live.ex │ ├── meerkat_live.ex │ ├── meerkat_live.html.heex │ ├── pagination_component.ex │ └── sorting_component.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ └── layout │ │ ├── app.html.heex │ │ ├── live.html.heex │ │ └── root.html.heex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ └── layout_view.ex ├── mix.exs ├── mix.lock ├── pragprog-book-tables-starter-app.zip ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ └── 20211105111549_create_meerkats.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ └── robots.txt └── test ├── meow └── meerkats_test.exs ├── meow_web ├── forms │ └── sorting_form_test.exs ├── live │ └── meow_live_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ ├── page_view_test.exs │ └── sorting_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex └── fixtures │ └── meerkats_fixtures.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: [ 68 | # 69 | ## Consistency Checks 70 | # 71 | {Credo.Check.Consistency.ExceptionNames, []}, 72 | {Credo.Check.Consistency.LineEndings, []}, 73 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 74 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 75 | {Credo.Check.Consistency.SpaceInParentheses, []}, 76 | {Credo.Check.Consistency.TabsOrSpaces, []}, 77 | 78 | # 79 | ## Design Checks 80 | # 81 | # You can customize the priority of any check 82 | # Priority values are: `low, normal, high, higher` 83 | # 84 | {Credo.Check.Design.AliasUsage, 85 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 86 | # You can also customize the exit_status of each check. 87 | # If you don't want TODO comments to cause `mix credo` to fail, just 88 | # set this value to 0 (zero). 89 | # 90 | {Credo.Check.Design.TagTODO, false}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | 93 | # 94 | ## Readability Checks 95 | # 96 | {Credo.Check.Readability.AliasOrder, []}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.LargeNumbers, []}, 99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 100 | {Credo.Check.Readability.ModuleAttributeNames, []}, 101 | {Credo.Check.Readability.ModuleDoc, false}, 102 | {Credo.Check.Readability.ModuleNames, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PredicateFunctionNames, []}, 106 | {Credo.Check.Readability.PreferImplicitTry, []}, 107 | {Credo.Check.Readability.RedundantBlankLines, []}, 108 | {Credo.Check.Readability.Semicolons, []}, 109 | {Credo.Check.Readability.SpaceAfterCommas, []}, 110 | {Credo.Check.Readability.StringSigils, []}, 111 | {Credo.Check.Readability.TrailingBlankLine, []}, 112 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 113 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 114 | {Credo.Check.Readability.VariableNames, []}, 115 | 116 | # 117 | ## Refactoring Opportunities 118 | # 119 | {Credo.Check.Refactor.CondStatements, []}, 120 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 121 | {Credo.Check.Refactor.FunctionArity, []}, 122 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 123 | # {Credo.Check.Refactor.MapInto, []}, 124 | {Credo.Check.Refactor.MatchInCondition, []}, 125 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 126 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 127 | {Credo.Check.Refactor.Nesting, []}, 128 | {Credo.Check.Refactor.UnlessWithElse, []}, 129 | {Credo.Check.Refactor.WithClauses, []}, 130 | 131 | # 132 | ## Warnings 133 | # 134 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 135 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 136 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 137 | {Credo.Check.Warning.IExPry, []}, 138 | {Credo.Check.Warning.IoInspect, []}, 139 | # {Credo.Check.Warning.LazyLogging, []}, 140 | {Credo.Check.Warning.MixEnv, false}, 141 | {Credo.Check.Warning.OperationOnSameValues, []}, 142 | {Credo.Check.Warning.OperationWithConstantResult, []}, 143 | {Credo.Check.Warning.RaiseInsideRescue, []}, 144 | {Credo.Check.Warning.UnusedEnumOperation, []}, 145 | {Credo.Check.Warning.UnusedFileOperation, []}, 146 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 147 | {Credo.Check.Warning.UnusedListOperation, []}, 148 | {Credo.Check.Warning.UnusedPathOperation, []}, 149 | {Credo.Check.Warning.UnusedRegexOperation, []}, 150 | {Credo.Check.Warning.UnusedStringOperation, []}, 151 | {Credo.Check.Warning.UnusedTupleOperation, []}, 152 | {Credo.Check.Warning.UnsafeExec, []}, 153 | 154 | # 155 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 156 | 157 | # 158 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 159 | # 160 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 161 | {Credo.Check.Consistency.UnusedVariableNames, false}, 162 | {Credo.Check.Design.DuplicatedCode, false}, 163 | {Credo.Check.Readability.AliasAs, false}, 164 | {Credo.Check.Readability.BlockPipe, false}, 165 | {Credo.Check.Readability.ImplTrue, false}, 166 | {Credo.Check.Readability.MultiAlias, false}, 167 | {Credo.Check.Readability.SeparateAliasRequire, false}, 168 | {Credo.Check.Readability.SinglePipe, false}, 169 | {Credo.Check.Readability.Specs, false}, 170 | {Credo.Check.Readability.StrictModuleLayout, false}, 171 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 172 | {Credo.Check.Refactor.ABCSize, false}, 173 | {Credo.Check.Refactor.AppendSingleItem, false}, 174 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 175 | {Credo.Check.Refactor.ModuleDependencies, false}, 176 | {Credo.Check.Refactor.NegatedIsNil, false}, 177 | {Credo.Check.Refactor.PipeChainStart, false}, 178 | {Credo.Check.Refactor.VariableRebinding, false}, 179 | {Credo.Check.Warning.LeakyEnvironment, false}, 180 | {Credo.Check.Warning.MapGetUnsafePass, false}, 181 | {Credo.Check.Warning.UnsafeToAtom, false} 182 | 183 | # 184 | # Custom checks can be created using `mix credo.gen.check`. 185 | # 186 | ] 187 | } 188 | ] 189 | } 190 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | meow-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.16.1-otp-26 2 | erlang 26.2.2 3 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build", 6 | "type": "shell", 7 | "command": "mix compile", 8 | "problemMatcher": ["$mixCompileError", "$mixCompileWarning"], 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | } 13 | }, 14 | { 15 | "label": "Format Current File", 16 | "type": "shell", 17 | "command": "mix", 18 | "args": ["format", "${relativeFile}"], 19 | "options": { 20 | "cwd": "${workspaceRoot}" 21 | }, 22 | "problemMatcher": "$mixTestFailure", 23 | "presentation": { 24 | "focus": false, 25 | "reveal": "never" 26 | } 27 | }, 28 | { 29 | "label": "Run All Tests", 30 | "type": "shell", 31 | "command": "mix", 32 | "args": ["test"], 33 | "options": { 34 | "cwd": "${workspaceRoot}" 35 | }, 36 | "problemMatcher": [ 37 | "$mixCompileError", 38 | "$mixCompileWarning", 39 | "$mixTestFailure" 40 | ], 41 | "group": { 42 | "kind": "test", 43 | "isDefault": true 44 | }, 45 | "presentation": { 46 | "focus": true 47 | } 48 | }, 49 | { 50 | "label": "Run Current Tests", 51 | "type": "shell", 52 | "command": "mix", 53 | "args": ["test", "${relativeFile}"], 54 | "options": { 55 | "cwd": "${workspaceRoot}" 56 | }, 57 | "problemMatcher": [ 58 | "$mixCompileError", 59 | "$mixCompileWarning", 60 | "$mixTestFailure" 61 | ], 62 | "presentation": { 63 | "focus": true 64 | } 65 | }, 66 | { 67 | "label": "Run Focused Test", 68 | "type": "shell", 69 | "command": "mix", 70 | "args": ["test", "${relativeFile}:${lineNumber}"], 71 | "options": { 72 | "cwd": "${workspaceRoot}" 73 | }, 74 | "problemMatcher": [ 75 | "$mixCompileError", 76 | "$mixCompileWarning", 77 | "$mixTestFailure" 78 | ], 79 | "presentation": { 80 | "focus": true 81 | } 82 | } 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meow 2 | 3 | This is the reference implementation of the demo project of the book `Build Table Views with Phoenix LiveView`. 4 | 5 | You can find the starter version of the application to which we'll add the code step-by-step in [pragprog-book-tables-starter-app.zip](https://github.com/PJUllrich/pragprog-book-tables/blob/main/pragprog-book-tables-starter-app.zip). It was created by the super kind [@thebrianemory](https://github.com/thebrianemory) who went through the book backwards and removed all code that we added! Now, you can download that version and add the code from the book step-by-step yourself. Super great work, Brian! Thank you so much! ❤️💛💙💚🧡 6 | 7 | To start your Phoenix server: 8 | 9 | * Install dependencies with `mix deps.get` 10 | * Create and migrate your database with `mix ecto.setup` 11 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 12 | 13 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 14 | 15 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 16 | 17 | ## Learn more 18 | 19 | * Official website: https://www.phoenixframework.org/ 20 | * Guides: https://hexdocs.pm/phoenix/overview.html 21 | * Docs: https://hexdocs.pm/phoenix 22 | * Forum: https://elixirforum.com/c/phoenix-forum 23 | * Source: https://github.com/phoenixframework/phoenix 24 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application CSS */ 2 | @import "./phoenix.css"; 3 | 4 | html, body { 5 | height: 100%; 6 | } 7 | 8 | .phx-click-loading { 9 | opacity: 0.5; 10 | transition: opacity 1s ease-out; 11 | } 12 | 13 | .phx-disconnected{ 14 | cursor: wait; 15 | } 16 | .phx-disconnected *{ 17 | pointer-events: none; 18 | } 19 | 20 | .sorting-header { 21 | cursor: pointer; 22 | } 23 | 24 | .pagination { 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | } 29 | 30 | .page-stepper { 31 | display: flex; 32 | justify-content: center; 33 | flex-wrap: wrap; 34 | cursor: pointer; 35 | margin-bottom: 2rem; 36 | } 37 | 38 | .pagination-button { 39 | border: 1px solid rgb(100, 100, 100); 40 | border-radius: 5px; 41 | min-width: 3rem; 42 | text-align: center; 43 | margin: 3px 2px; 44 | color: rgb(100, 100, 100); 45 | font-weight: 400; 46 | } 47 | 48 | .pagination-button.active { 49 | border: 1px solid #000; 50 | color: #fff; 51 | background-color: #000; 52 | } 53 | 54 | #table-filter .row { 55 | display: flex; 56 | align-items: flex-end; 57 | } 58 | 59 | #table-filter .row div { 60 | margin-right: 1rem; 61 | } 62 | 63 | #table-filter .btn-submit { 64 | margin-bottom: 0.5rem; 65 | } 66 | 67 | .w-30 { 68 | width: 30%; 69 | } 70 | 71 | .w-15 { 72 | width: 15%; 73 | } -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.4.1 https://milligram.github.io 6 | * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica 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} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | import "../css/app.css"; 4 | 5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 6 | // to get started and then uncomment the line below. 7 | // import "./user_socket.js" 8 | 9 | // You can include dependencies in two ways. 10 | // 11 | // The simplest option is to put them in assets/vendor and 12 | // import them using relative paths: 13 | // 14 | // import "./vendor/some-package.js" 15 | // 16 | // Alternatively, you can `npm install some-package` and import 17 | // them using a path starting with the package name: 18 | // 19 | // import "some-package" 20 | // 21 | 22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 23 | import "phoenix_html"; 24 | // Establish Phoenix Socket and LiveView configuration. 25 | import { Socket } from "phoenix"; 26 | import { LiveSocket } from "phoenix_live_view"; 27 | import topbar from "../vendor/topbar"; 28 | 29 | import InfinityScroll from "./infinity-scroll"; 30 | import PingPongHook from "./ping-pong-hook"; 31 | 32 | let Hooks = {}; 33 | Hooks.InfinityScroll = InfinityScroll; 34 | Hooks.PingPongHook = PingPongHook; 35 | 36 | let csrfToken = document 37 | .querySelector("meta[name='csrf-token']") 38 | .getAttribute("content"); 39 | let liveSocket = new LiveSocket("/live", Socket, { 40 | params: { _csrf_token: csrfToken }, 41 | hooks: Hooks, 42 | }); 43 | 44 | // Show progress bar on live navigation and form submits 45 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); 46 | window.addEventListener("phx:page-loading-start", (info) => topbar.show()); 47 | window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); 48 | 49 | // connect if there are any LiveViews on the page 50 | liveSocket.connect(); 51 | 52 | // expose liveSocket on window for web console debug logs and latency simulation: 53 | // >> liveSocket.enableDebug() 54 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 55 | // >> liveSocket.disableLatencySim() 56 | window.liveSocket = liveSocket; 57 | -------------------------------------------------------------------------------- /assets/js/infinity-scroll.js: -------------------------------------------------------------------------------- 1 | export default { 2 | rootElement() { 3 | return ( 4 | document.documentElement || document.body.parentNode || document.body 5 | ); 6 | }, 7 | scrollPosition() { 8 | const { scrollTop, clientHeight, scrollHeight } = this.rootElement(); 9 | 10 | return ((scrollTop + clientHeight) / scrollHeight) * 100; 11 | }, 12 | scrollEventListener() { 13 | const currentScrollPosition = this.scrollPosition(); 14 | 15 | const isCloseToBottom = 16 | currentScrollPosition > this.threshold && 17 | this.lastScrollPosition <= this.threshold; 18 | 19 | if (isCloseToBottom) this.pushEvent("load-more", {}); 20 | 21 | this.lastScrollPosition = currentScrollPosition; 22 | }, 23 | mounted() { 24 | this.threshold = 90; 25 | this.lastScrollPosition = 0; 26 | 27 | window.addEventListener("scroll", () => this.scrollEventListener()); 28 | }, 29 | destroyed() { 30 | // Upon destruction of the table, we need to remove the event listener again. 31 | // Otherwise, we might end up with multiple listeners that might fetch data from dead LiveViews. 32 | // This change was suggested by `Nicolas Blanco` here: https://github.com/PJUllrich/pragprog-book-tables/issues/1 33 | // Thank you very much, Nicolas! 34 | window.removeEventListener("scroll", () => this.scrollEventListener()); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /assets/js/ping-pong-hook.js: -------------------------------------------------------------------------------- 1 | export default { 2 | addPongListener() { 3 | window.addEventListener("phx:pong", (event) => { 4 | console.log(event.type); 5 | console.log(event.detail.message); 6 | }); 7 | }, 8 | sendPing() { 9 | this.pushEvent("ping", { myVar: 1 }); 10 | }, 11 | mounted() { 12 | console.log("I'm alive!"); 13 | this.addPongListener(); 14 | this.sendPing(); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * http://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :meow, 11 | ecto_repos: [Meow.Repo] 12 | 13 | # Configures the endpoint 14 | config :meow, MeowWeb.Endpoint, 15 | url: [host: "localhost"], 16 | render_errors: [view: MeowWeb.ErrorView, accepts: ~w(html json), layout: false], 17 | pubsub_server: Meow.PubSub, 18 | live_view: [signing_salt: "GvHsMFRB"] 19 | 20 | # Configure esbuild (the version is required) 21 | config :esbuild, 22 | version: "0.12.18", 23 | default: [ 24 | args: 25 | ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 26 | cd: Path.expand("../assets", __DIR__), 27 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 28 | ] 29 | 30 | # Configures Elixir's Logger 31 | config :logger, :console, 32 | format: "$time $metadata[$level] $message\n", 33 | metadata: [:request_id] 34 | 35 | # Use Jason for JSON parsing in Phoenix 36 | config :phoenix, :json_library, Jason 37 | 38 | # Import environment specific config. This must remain at the bottom 39 | # of this file so it overrides the configuration defined above. 40 | import_config "#{config_env()}.exs" 41 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :meow, Meow.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "meow_dev", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with esbuild to bundle .js and .css sources. 18 | config :meow, MeowWeb.Endpoint, 19 | # Binding to loopback ipv4 address prevents access from other machines. 20 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 21 | http: [ip: {127, 0, 0, 1}, port: 4000], 22 | check_origin: false, 23 | code_reloader: true, 24 | debug_errors: true, 25 | secret_key_base: "l6RdVZx08vlgoN4bK5TJubdcsYJ7O3prT5E+8KTB5u4mko6EpNCTtaKp7tAy3AXs", 26 | watchers: [ 27 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 28 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 29 | ] 30 | 31 | # ## SSL Support 32 | # 33 | # In order to use HTTPS in development, a self-signed 34 | # certificate can be generated by running the following 35 | # Mix task: 36 | # 37 | # mix phx.gen.cert 38 | # 39 | # Note that this task requires Erlang/OTP 20 or later. 40 | # Run `mix help phx.gen.cert` for more information. 41 | # 42 | # The `http:` config above can be replaced with: 43 | # 44 | # https: [ 45 | # port: 4001, 46 | # cipher_suite: :strong, 47 | # keyfile: "priv/cert/selfsigned_key.pem", 48 | # certfile: "priv/cert/selfsigned.pem" 49 | # ], 50 | # 51 | # If desired, both `http:` and `https:` keys can be 52 | # configured to run both http and https servers on 53 | # different ports. 54 | 55 | # Watch static and templates for browser reloading. 56 | config :meow, MeowWeb.Endpoint, 57 | live_reload: [ 58 | patterns: [ 59 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 60 | ~r"lib/meow_web/(live|views)/.*(ex)$", 61 | ~r"lib/meow_web/templates/.*(eex)$" 62 | ] 63 | ] 64 | 65 | # Do not include metadata nor timestamps in development logs 66 | config :logger, :console, format: "[$level] $message\n" 67 | 68 | # Set a higher stacktrace during development. Avoid configuring such 69 | # in production as building large stacktraces may be expensive. 70 | config :phoenix, :stacktrace_depth, 20 71 | 72 | # Initialize plugs at runtime for faster development compilation 73 | config :phoenix, :plug_init_mode, :runtime 74 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :meow, MeowWeb.Endpoint, 13 | url: [host: "example.com", port: 80], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :meow, MeowWeb.Endpoint, 25 | # ..., 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # ..., 29 | # port: 443, 30 | # cipher_suite: :strong, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :meow, MeowWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | if config_env() == :prod do 10 | database_url = 11 | System.get_env("DATABASE_URL") || 12 | raise """ 13 | environment variable DATABASE_URL is missing. 14 | For example: ecto://USER:PASS@HOST/DATABASE 15 | """ 16 | 17 | config :meow, Meow.Repo, 18 | # ssl: true, 19 | # socket_options: [:inet6], 20 | url: database_url, 21 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 22 | 23 | # The secret key base is used to sign/encrypt cookies and other secrets. 24 | # A default value is used in config/dev.exs and config/test.exs but you 25 | # want to use a different value for prod and you most likely don't want 26 | # to check this value into version control, so we use an environment 27 | # variable instead. 28 | secret_key_base = 29 | System.get_env("SECRET_KEY_BASE") || 30 | raise """ 31 | environment variable SECRET_KEY_BASE is missing. 32 | You can generate one by calling: mix phx.gen.secret 33 | """ 34 | 35 | config :meow, MeowWeb.Endpoint, 36 | http: [ 37 | # Enable IPv6 and bind on all interfaces. 38 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 39 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 40 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 41 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 42 | port: String.to_integer(System.get_env("PORT") || "4000") 43 | ], 44 | secret_key_base: secret_key_base 45 | 46 | # ## Using releases 47 | # 48 | # If you are doing OTP releases, you need to instruct Phoenix 49 | # to start each relevant endpoint: 50 | # 51 | # config :meow, MeowWeb.Endpoint, server: true 52 | # 53 | # Then you can assemble a release by calling `mix release`. 54 | # See `mix help release` for more information. 55 | end 56 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :meow, Meow.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | database: "meow_test#{System.get_env("MIX_TEST_PARTITION")}", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :meow, MeowWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "ENNLAkz3Gom2a8DZzlk/YfJ1CDzHolvTjV6KiT81ehOgooP3e2OoWUgwNCDKR1T8", 21 | server: false 22 | 23 | # Print only warnings and errors during test 24 | config :logger, level: :warn 25 | 26 | # Initialize plugs at runtime for faster test compilation 27 | config :phoenix, :plug_init_mode, :runtime 28 | -------------------------------------------------------------------------------- /lib/meow.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow do 2 | @moduledoc """ 3 | Meow keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/meow/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Ecto repository 12 | Meow.Repo, 13 | # Start the Telemetry supervisor 14 | MeowWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: Meow.PubSub}, 17 | # Start the Endpoint (http/https) 18 | MeowWeb.Endpoint 19 | # Start a worker by calling: Meow.Worker.start_link(arg) 20 | # {Meow.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: Meow.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | @impl true 32 | def config_change(changed, _new, removed) do 33 | MeowWeb.Endpoint.config_change(changed, removed) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/meow/ecto_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.EctoHelper do 2 | def enum(values) do 3 | # An alternative to the code below could also be: 4 | # Ecto.ParameterizedType.init(Ecto.Enum, values: values) 5 | # 6 | # As suggested by Benjamin Milde here: https://github.com/PJUllrich/pragprog-book-tables/issues/2 7 | # Thank you, Benjamin! 8 | 9 | {:parameterized, Ecto.Enum, Ecto.Enum.init(values: values)} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/meow/meerkats.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.Meerkats do 2 | @moduledoc """ 3 | The Meerkats context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Meow.Repo 8 | 9 | alias Meow.Meerkats.Meerkat 10 | 11 | def meerkat_count(), do: Repo.aggregate(Meerkat, :count) 12 | 13 | def list_meerkats(opts) do 14 | from(m in Meerkat) 15 | |> sort(opts) 16 | |> filter(opts) 17 | |> Repo.all() 18 | end 19 | 20 | def list_meerkats_with_pagination(offset, limit) do 21 | from(m in Meerkat) 22 | |> limit(^limit) 23 | |> offset(^offset) 24 | |> Repo.all() 25 | end 26 | 27 | def list_meerkats_with_total_count(opts) do 28 | query = from(m in Meerkat) |> filter(opts) 29 | 30 | total_count = Repo.aggregate(query, :count) 31 | 32 | result = 33 | query 34 | |> sort(opts) 35 | |> paginate(opts) 36 | |> Repo.all() 37 | 38 | %{meerkats: result, total_count: total_count} 39 | end 40 | 41 | defp sort(query, %{sort_dir: sort_dir, sort_by: sort_by}) 42 | when sort_dir in [:asc, :desc] and 43 | sort_by in [:id, :name] do 44 | order_by(query, {^sort_dir, ^sort_by}) 45 | end 46 | 47 | defp sort(query, _opts), do: query 48 | 49 | defp paginate(query, %{page: page, page_size: page_size}) 50 | when is_integer(page) and is_integer(page_size) do 51 | offset = max(page - 1, 0) * page_size 52 | 53 | query 54 | |> limit(^page_size) 55 | |> offset(^offset) 56 | end 57 | 58 | defp paginate(query, _opts), do: query 59 | 60 | defp filter(query, opts) do 61 | query 62 | |> filter_by_id(opts) 63 | |> filter_by_name(opts) 64 | end 65 | 66 | defp filter_by_id(query, %{id: id}) when is_integer(id) do 67 | where(query, id: ^id) 68 | end 69 | 70 | defp filter_by_id(query, _opts), do: query 71 | 72 | defp filter_by_name(query, %{name: name}) 73 | when is_binary(name) and name != "" do 74 | query_string = "%#{name}%" 75 | where(query, [m], ilike(m.name, ^query_string)) 76 | end 77 | 78 | defp filter_by_name(query, _opts), do: query 79 | 80 | @doc """ 81 | Gets a single meerkat. 82 | 83 | Raises `Ecto.NoResultsError` if the Meerkat does not exist. 84 | 85 | ## Examples 86 | 87 | iex> get_meerkat!(123) 88 | %Meerkat{} 89 | 90 | iex> get_meerkat!(456) 91 | ** (Ecto.NoResultsError) 92 | 93 | """ 94 | def get_meerkat!(id), do: Repo.get!(Meerkat, id) 95 | 96 | @doc """ 97 | Creates a meerkat. 98 | 99 | ## Examples 100 | 101 | iex> create_meerkat(%{field: value}) 102 | {:ok, %Meerkat{}} 103 | 104 | iex> create_meerkat(%{field: bad_value}) 105 | {:error, %Ecto.Changeset{}} 106 | 107 | """ 108 | def create_meerkat(attrs \\ %{}) do 109 | %Meerkat{} 110 | |> Meerkat.changeset(attrs) 111 | |> Repo.insert() 112 | end 113 | 114 | @doc """ 115 | Updates a meerkat. 116 | 117 | ## Examples 118 | 119 | iex> update_meerkat(meerkat, %{field: new_value}) 120 | {:ok, %Meerkat{}} 121 | 122 | iex> update_meerkat(meerkat, %{field: bad_value}) 123 | {:error, %Ecto.Changeset{}} 124 | 125 | """ 126 | def update_meerkat(%Meerkat{} = meerkat, attrs) do 127 | meerkat 128 | |> Meerkat.changeset(attrs) 129 | |> Repo.update() 130 | end 131 | 132 | @doc """ 133 | Deletes a meerkat. 134 | 135 | ## Examples 136 | 137 | iex> delete_meerkat(meerkat) 138 | {:ok, %Meerkat{}} 139 | 140 | iex> delete_meerkat(meerkat) 141 | {:error, %Ecto.Changeset{}} 142 | 143 | """ 144 | def delete_meerkat(%Meerkat{} = meerkat) do 145 | Repo.delete(meerkat) 146 | end 147 | 148 | @doc """ 149 | Returns an `%Ecto.Changeset{}` for tracking meerkat changes. 150 | 151 | ## Examples 152 | 153 | iex> change_meerkat(meerkat) 154 | %Ecto.Changeset{data: %Meerkat{}} 155 | 156 | """ 157 | def change_meerkat(%Meerkat{} = meerkat, attrs \\ %{}) do 158 | Meerkat.changeset(meerkat, attrs) 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/meow/meerkats/meerkat.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.Meerkats.Meerkat do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "meerkats" do 6 | field(:name, :string) 7 | 8 | timestamps() 9 | end 10 | 11 | @doc false 12 | def changeset(meerkat, attrs) do 13 | meerkat 14 | |> cast(attrs, [:name]) 15 | |> validate_required([:name]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/meow/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.Repo do 2 | use Ecto.Repo, 3 | otp_app: :meow, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/meow_web.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use MeowWeb, :controller 9 | use MeowWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: MeowWeb 23 | 24 | import Plug.Conn 25 | alias MeowWeb.Router.Helpers, as: Routes 26 | end 27 | end 28 | 29 | def view do 30 | quote do 31 | use Phoenix.View, 32 | root: "lib/meow_web/templates", 33 | namespace: MeowWeb 34 | 35 | use Phoenix.Component 36 | 37 | # Import convenience functions from controllers 38 | import Phoenix.Controller, 39 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 40 | 41 | # Include shared imports and aliases for views 42 | unquote(view_helpers()) 43 | end 44 | end 45 | 46 | def live_view do 47 | quote do 48 | use Phoenix.LiveView, 49 | layout: {MeowWeb.LayoutView, :live} 50 | 51 | unquote(view_helpers()) 52 | end 53 | end 54 | 55 | def live_component do 56 | quote do 57 | use Phoenix.LiveComponent 58 | 59 | unquote(view_helpers()) 60 | end 61 | end 62 | 63 | def router do 64 | quote do 65 | use Phoenix.Router 66 | 67 | import Plug.Conn 68 | import Phoenix.Controller 69 | import Phoenix.LiveView.Router 70 | end 71 | end 72 | 73 | def channel do 74 | quote do 75 | use Phoenix.Channel 76 | end 77 | end 78 | 79 | defp view_helpers do 80 | quote do 81 | # Use all HTML functionality (forms, tags, etc) 82 | import Phoenix.HTML 83 | import Phoenix.HTML.Form 84 | use PhoenixHTMLHelpers 85 | 86 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 87 | import Phoenix.LiveView.Helpers 88 | 89 | # Import basic rendering functionality (render, render_layout, etc) 90 | import Phoenix.View 91 | 92 | import MeowWeb.ErrorHelpers 93 | alias MeowWeb.Router.Helpers, as: Routes 94 | end 95 | end 96 | 97 | @doc """ 98 | When used, dispatch to the appropriate controller/view/etc. 99 | """ 100 | defmacro __using__(which) when is_atom(which) do 101 | apply(__MODULE__, which, []) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/meow_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :meow 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_meow_key", 10 | signing_salt: "5ExCbWWS" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :meow, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :meow 32 | end 33 | 34 | plug Plug.RequestId 35 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 36 | 37 | plug Plug.Parsers, 38 | parsers: [:urlencoded, :multipart, :json], 39 | pass: ["*/*"], 40 | json_decoder: Phoenix.json_library() 41 | 42 | plug Plug.MethodOverride 43 | plug Plug.Head 44 | plug Plug.Session, @session_options 45 | plug MeowWeb.Router 46 | end 47 | -------------------------------------------------------------------------------- /lib/meow_web/forms/filter_form.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Forms.FilterForm do 2 | import Ecto.Changeset 3 | 4 | @fields %{ 5 | id: :integer, 6 | name: :string 7 | } 8 | 9 | @default_values %{ 10 | id: nil, 11 | name: nil 12 | } 13 | 14 | def default_values(overrides \\ %{}) do 15 | Map.merge(@default_values, overrides) 16 | end 17 | 18 | def parse(params) do 19 | {@default_values, @fields} 20 | |> cast(params, Map.keys(@fields)) 21 | |> validate_number(:id, greater_than_or_equal_to: 0) 22 | |> apply_action(:insert) 23 | end 24 | 25 | def change_values(values \\ @default_values) do 26 | {values, @fields} 27 | |> cast(%{}, Map.keys(@fields)) 28 | end 29 | 30 | def contains_filter_values?(opts) do 31 | @fields 32 | |> Map.keys() 33 | |> Enum.any?(fn key -> Map.get(opts, key) end) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/meow_web/forms/pagination_form.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Forms.PaginationForm do 2 | import Ecto.Changeset 3 | 4 | @fields %{ 5 | page: :integer, 6 | page_size: :integer, 7 | total_count: :integer 8 | } 9 | 10 | @default_values %{ 11 | page: 1, 12 | page_size: 20, 13 | total_count: 0 14 | } 15 | 16 | def parse(params, values \\ @default_values) do 17 | {values, @fields} 18 | |> cast(params, Map.keys(@fields)) 19 | |> validate_number(:page, greater_than_or_equal_to: 0) 20 | |> validate_number(:page_size, greater_than: 0) 21 | |> validate_number(:total_count, greater_than_or_equal_to: 0) 22 | |> apply_action(:insert) 23 | end 24 | 25 | def default_values(overrides \\ %{}), do: Map.merge(@default_values, overrides) 26 | end 27 | -------------------------------------------------------------------------------- /lib/meow_web/forms/sorting_form.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Forms.SortingForm do 2 | import Ecto.Changeset 3 | 4 | alias Meow.EctoHelper 5 | 6 | @fields %{ 7 | sort_by: EctoHelper.enum([:id, :name]), 8 | sort_dir: EctoHelper.enum([:asc, :desc]) 9 | } 10 | 11 | @default_values %{ 12 | sort_by: :id, 13 | sort_dir: :asc 14 | } 15 | 16 | def parse(params) do 17 | {@default_values, @fields} 18 | |> cast(params, Map.keys(@fields)) 19 | |> apply_action(:insert) 20 | end 21 | 22 | def default_values(overrides \\ %{}), do: Map.merge(@default_values, overrides) 23 | end 24 | -------------------------------------------------------------------------------- /lib/meow_web/live/filter_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.MeerkatLive.FilterComponent do 2 | use MeowWeb, :live_component 3 | 4 | alias MeowWeb.Forms.FilterForm 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
9 | <.form :let={f} for={@changeset} as={:filter} phx-submit="search" phx-target={@myself} > 10 |
11 |
12 | <%= label f, :id %> 13 | <%= number_input f, :id %> 14 | <%= error_tag f, :id %> 15 |
16 |
17 | <%= label f, :name %> 18 | <%= text_input f, :name %> 19 | <%= error_tag f, :name %> 20 |
21 |
22 | <%= submit "Search" %> 23 |
24 |
25 | 26 |
27 | """ 28 | end 29 | 30 | def update(assigns, socket) do 31 | {:ok, assign_changeset(assigns, socket)} 32 | end 33 | 34 | def handle_event("search", %{"filter" => filter}, socket) do 35 | case FilterForm.parse(filter) do 36 | {:ok, opts} -> 37 | send(self(), {:update, opts}) 38 | {:noreply, socket} 39 | 40 | {:error, changeset} -> 41 | {:noreply, assign(socket, :changeset, changeset)} 42 | end 43 | end 44 | 45 | defp assign_changeset(%{filter: filter}, socket) do 46 | assign(socket, :changeset, FilterForm.change_values(filter)) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/meow_web/live/infinity_live.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.InfinityLive do 2 | use MeowWeb, :live_view 3 | 4 | alias Meow.Meerkats 5 | 6 | def render(assigns) do 7 | ~H""" 8 | 9 |
10 |
13 | <%= for {dom_id, meerkat} <- @streams.meerkats do %> 14 | 15 | 16 | 17 | 18 | <% end %> 19 | 20 |
<%= meerkat.id %><%= meerkat.name %>
21 | """ 22 | end 23 | 24 | def mount(_params, _session, socket) do 25 | count = Meerkats.meerkat_count() 26 | 27 | socket = 28 | socket 29 | |> assign(offset: 0, limit: 25, count: count) 30 | |> load_meerkats() 31 | 32 | {:ok, socket} 33 | end 34 | 35 | def handle_event("ping", params, socket) do 36 | IO.inspect("ping", label: "Event") 37 | IO.inspect(params, label: "Params") 38 | {:noreply, push_event(socket, "pong", %{message: "Hello there!"})} 39 | end 40 | 41 | def handle_event("load-more", _params, socket) do 42 | %{offset: offset, limit: limit, count: count} = socket.assigns 43 | 44 | socket = 45 | if offset < count do 46 | socket 47 | |> assign(offset: offset + limit) 48 | |> load_meerkats() 49 | else 50 | socket 51 | end 52 | 53 | {:noreply, socket} 54 | end 55 | 56 | defp load_meerkats(%{assigns: %{offset: offset, limit: limit}} = socket) do 57 | meerkats = Meerkats.list_meerkats_with_pagination(offset, limit) 58 | stream(socket, :meerkats, meerkats) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/meow_web/live/meerkat_live.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.MeerkatLive do 2 | use MeowWeb, :live_view 3 | 4 | alias Meow.Meerkats 5 | alias MeowWeb.Forms.SortingForm 6 | alias MeowWeb.Forms.PaginationForm 7 | alias MeowWeb.Forms.FilterForm 8 | 9 | @impl true 10 | def mount(_params, _session, socket), do: {:ok, socket} 11 | 12 | @impl true 13 | def handle_params(params, _url, socket) do 14 | socket = 15 | socket 16 | |> parse_params(params) 17 | |> assign_meerkats() 18 | 19 | {:noreply, socket} 20 | end 21 | 22 | @impl true 23 | def handle_info({:update, opts}, socket) do 24 | params = merge_and_sanitize_params(socket, opts) 25 | path = Routes.live_path(socket, __MODULE__, params) 26 | {:noreply, push_patch(socket, to: path, replace: true)} 27 | end 28 | 29 | defp parse_params(socket, params) do 30 | with {:ok, sorting_opts} <- SortingForm.parse(params), 31 | {:ok, filter_opts} <- FilterForm.parse(params), 32 | {:ok, pagination_opts} <- PaginationForm.parse(params) do 33 | socket 34 | |> assign_sorting(sorting_opts) 35 | |> assign_filter(filter_opts) 36 | |> assign_pagination(pagination_opts) 37 | else 38 | _error -> 39 | socket 40 | |> assign_sorting() 41 | |> assign_filter() 42 | |> assign_pagination() 43 | end 44 | end 45 | 46 | defp assign_meerkats(socket) do 47 | params = merge_and_sanitize_params(socket) 48 | 49 | %{meerkats: meerkats, total_count: total_count} = 50 | Meerkats.list_meerkats_with_total_count(params) 51 | 52 | socket 53 | |> assign(:meerkats, meerkats) 54 | |> assign_total_count(total_count) 55 | end 56 | 57 | defp merge_and_sanitize_params(socket, overrides \\ %{}) do 58 | %{sorting: sorting, pagination: pagination, filter: filter} = socket.assigns 59 | overrides = maybe_reset_pagination(overrides) 60 | 61 | %{} 62 | |> Map.merge(sorting) 63 | |> Map.merge(pagination) 64 | |> Map.merge(filter) 65 | |> Map.merge(overrides) 66 | |> Map.drop([:total_count]) 67 | |> Enum.reject(fn {_key, value} -> is_nil(value) end) 68 | |> Map.new() 69 | end 70 | 71 | defp assign_sorting(socket, overrides \\ %{}) do 72 | assign(socket, :sorting, SortingForm.default_values(overrides)) 73 | end 74 | 75 | defp assign_pagination(socket, overrides \\ %{}) do 76 | assign(socket, :pagination, PaginationForm.default_values(overrides)) 77 | end 78 | 79 | defp assign_filter(socket, overrides \\ %{}) do 80 | assign(socket, :filter, FilterForm.default_values(overrides)) 81 | end 82 | 83 | defp assign_total_count(socket, total_count) do 84 | update(socket, :pagination, fn pagination -> %{pagination | total_count: total_count} end) 85 | end 86 | 87 | defp maybe_reset_pagination(overrides) do 88 | if FilterForm.contains_filter_values?(overrides) do 89 | Map.put(overrides, :page, 1) 90 | else 91 | overrides 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/meow_web/live/meerkat_live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | <.live_component module={MeowWeb.MeerkatLive.FilterComponent} id="filter" filter={@filter} /> 4 | 5 | 6 | 7 | 8 | 15 | 18 | 19 | 20 | 21 | <%= if assigns[:error_message] do %> 22 | 23 | 24 | 25 | <% else %> 26 | <%= for meerkat <- @meerkats do %> 27 | 28 | 29 | 30 | 31 | <% end %> 32 | <% end %> 33 | 34 |
9 | <.live_component 10 | module={MeowWeb.MeerkatLive.SortingComponent} 11 | id={"sorting-id"} 12 | key={:id} 13 | sorting={@sorting} /> 14 | 16 | <.live_component module={MeowWeb.MeerkatLive.SortingComponent} id={"sorting-name"} key={:name} sorting={@sorting} /> 17 |
<%= @error_message %>
<%= meerkat.id %><%= meerkat.name %>
35 | 36 | <.live_component module={MeowWeb.MeerkatLive.PaginationComponent} id="pagination" pagination={@pagination} /> 37 |
-------------------------------------------------------------------------------- /lib/meow_web/live/pagination_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.MeerkatLive.PaginationComponent do 2 | use MeowWeb, :live_component 3 | 4 | alias MeowWeb.Forms.PaginationForm 5 | 6 | @impl true 7 | def render(assigns) do 8 | ~H""" 9 | 23 | """ 24 | end 25 | 26 | @impl true 27 | def handle_event("show_page", params, socket) do 28 | parse_params(params, socket) 29 | end 30 | 31 | @impl true 32 | def handle_event("set_page_size", %{"page_size" => params}, socket) do 33 | parse_params(params, socket) 34 | end 35 | 36 | defp parse_params(params, socket) do 37 | %{pagination: pagination} = socket.assigns 38 | 39 | case PaginationForm.parse(params, pagination) do 40 | {:ok, opts} -> 41 | send(self(), {:update, opts}) 42 | {:noreply, socket} 43 | 44 | {:error, _changeset} -> 45 | {:noreply, socket} 46 | end 47 | end 48 | 49 | def pages(%{page_size: page_size, page: current_page, total_count: total_count}) do 50 | page_count = ceil(total_count / page_size) 51 | 52 | for page <- 1..page_count//1 do 53 | current_page? = page == current_page 54 | 55 | {page, current_page?} 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/meow_web/live/sorting_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.MeerkatLive.SortingComponent do 2 | use MeowWeb, :live_component 3 | 4 | def render(assigns) do 5 | ~H""" 6 |
7 | <%= @key %> <%= chevron(@sorting, @key) %> 8 |
9 | """ 10 | end 11 | 12 | def handle_event("sort_by_key", _params, socket) do 13 | %{sorting: %{sort_dir: sort_dir}, key: key} = socket.assigns 14 | 15 | sort_dir = if sort_dir == :asc, do: :desc, else: :asc 16 | opts = %{sort_by: key, sort_dir: sort_dir} 17 | 18 | send(self(), {:update, opts}) 19 | {:noreply, socket} 20 | end 21 | 22 | def chevron(%{sort_by: sort_by, sort_dir: sort_dir}, key) when sort_by == key do 23 | if sort_dir == :asc, do: "⇧", else: "⇩" 24 | end 25 | 26 | def chevron(_opts, _key), do: "" 27 | end 28 | -------------------------------------------------------------------------------- /lib/meow_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Router do 2 | use MeowWeb, :router 3 | 4 | pipeline :browser do 5 | plug(:accepts, ["html"]) 6 | plug(:fetch_session) 7 | plug(:fetch_live_flash) 8 | plug(:put_root_layout, {MeowWeb.LayoutView, :root}) 9 | plug(:protect_from_forgery) 10 | plug(:put_secure_browser_headers) 11 | end 12 | 13 | pipeline :api do 14 | plug(:accepts, ["json"]) 15 | end 16 | 17 | scope "/", MeowWeb do 18 | pipe_through(:browser) 19 | 20 | live("/", MeerkatLive) 21 | live("/infinity", InfinityLive) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/meow_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("meow.repo.query.total_time", 35 | unit: {:native, :millisecond}, 36 | description: "The sum of the other measurements" 37 | ), 38 | summary("meow.repo.query.decode_time", 39 | unit: {:native, :millisecond}, 40 | description: "The time spent decoding the data received from the database" 41 | ), 42 | summary("meow.repo.query.query_time", 43 | unit: {:native, :millisecond}, 44 | description: "The time spent executing the query" 45 | ), 46 | summary("meow.repo.query.queue_time", 47 | unit: {:native, :millisecond}, 48 | description: "The time spent waiting for a database connection" 49 | ), 50 | summary("meow.repo.query.idle_time", 51 | unit: {:native, :millisecond}, 52 | description: 53 | "The time the connection spent waiting before being checked out for the query" 54 | ), 55 | 56 | # VM Metrics 57 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 58 | summary("vm.total_run_queue_lengths.total"), 59 | summary("vm.total_run_queue_lengths.cpu"), 60 | summary("vm.total_run_queue_lengths.io") 61 | ] 62 | end 63 | 64 | defp periodic_measurements do 65 | [ 66 | # A module, function and arguments to be invoked periodically. 67 | # This function must call :telemetry.execute/3 and a metric must be added above. 68 | # {MeowWeb, :count_users, []} 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/meow_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /lib/meow_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /lib/meow_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title> 9 | <%= assigns[:page_title] || "Meow" %> 10 | 11 | 12 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/meow_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | import Phoenix.HTML.Form 7 | use PhoenixHTMLHelpers 8 | 9 | @doc """ 10 | Generates tag for inlined form input errors. 11 | """ 12 | def error_tag(form, field) do 13 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 14 | content_tag(:span, translate_error(error), 15 | class: "invalid-feedback", 16 | phx_feedback_for: input_name(form, field) 17 | ) 18 | end) 19 | end 20 | 21 | @doc """ 22 | Translates an error message. 23 | """ 24 | def translate_error({msg, opts}) do 25 | # Because the error messages we show in our forms and APIs 26 | # are defined inside Ecto, we need to translate them dynamically. 27 | Enum.reduce(opts, msg, fn {key, value}, acc -> 28 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 29 | end) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/meow_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.ErrorView do 2 | use MeowWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/meow_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.LayoutView do 2 | use MeowWeb, :view 3 | 4 | # Phoenix LiveDashboard is available only in development by default, 5 | # so we instruct Elixir to not warn if the dashboard route is missing. 6 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 7 | end 8 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Meow.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :meow, 7 | version: "0.1.0", 8 | elixir: "~> 1.16", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Meow.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.7"}, 37 | {:phoenix_ecto, "~> 4.4"}, 38 | {:ecto_sql, "~> 3.6"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_html, "~> 4.1"}, 41 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 42 | {:phoenix_live_view, "~> 0.20"}, 43 | {:phoenix_html_helpers, "~> 1.0"}, 44 | {:phoenix_view, "~> 2.0"}, 45 | {:floki, ">= 0.30.0", only: :test}, 46 | {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, 47 | {:telemetry_metrics, "~> 1.0"}, 48 | {:telemetry_poller, "~> 1.0"}, 49 | {:jason, "~> 1.2"}, 50 | {:plug_cowboy, "~> 2.5"} 51 | ] 52 | end 53 | 54 | # Aliases are shortcuts or tasks specific to the current project. 55 | # For example, to install project dependencies and perform other setup tasks, run: 56 | # 57 | # $ mix setup 58 | # 59 | # See the documentation for `Mix` for more info on aliases. 60 | defp aliases do 61 | [ 62 | setup: ["deps.get", "ecto.setup"], 63 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 64 | "ecto.reset": ["ecto.drop", "ecto.setup"], 65 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 66 | "assets.deploy": ["esbuild default --minify", "phx.digest"] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, 3 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 4 | "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, 5 | "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"}, 6 | "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, 7 | "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, 8 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 9 | "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, 11 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 12 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 13 | "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, 14 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 15 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 16 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 17 | "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, 18 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.5.1", "6fdbc334ea53620e71655664df6f33f670747b3a7a6c4041cdda3e2c32df6257", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ebe43aa580db129e54408e719fb9659b7f9e0d52b965c5be26cdca416ecead28"}, 19 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 20 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 21 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, 22 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, 23 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 24 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 25 | "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, 26 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 27 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, 28 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 29 | "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, 30 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 31 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 32 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 33 | "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, 34 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 35 | "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, 36 | } 37 | -------------------------------------------------------------------------------- /pragprog-book-tables-starter-app.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PJUllrich/pragprog-book-tables/66e67a994a3f591606175151cccdda624ba9d7b8/pragprog-book-tables-starter-app.zip -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20211105111549_create_meerkats.exs: -------------------------------------------------------------------------------- 1 | defmodule Meow.Repo.Migrations.CreateMeerkats do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:meerkats) do 6 | add(:name, :string) 7 | add(:gender, :string) 8 | add(:weight, :integer) 9 | 10 | timestamps() 11 | end 12 | 13 | create index(:meerkats, :name) 14 | create index(:meerkats, :weight) 15 | create index(:meerkats, :gender) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Meow.Repo.insert!(%Meow.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | alias Meow.Repo 14 | alias Meow.Meerkats.Meerkat 15 | 16 | name_prefix = [ 17 | "Big", 18 | "Little", 19 | "Cat", 20 | "Kat", 21 | "Meery", 22 | "Miri", 23 | "Kattie", 24 | "Rusty", 25 | "Mean", 26 | "Sweet", 27 | "Cute", 28 | "Slim" 29 | ] 30 | 31 | names = [ 32 | "Cat Benatar", 33 | "Jennifurr", 34 | "Meowsie", 35 | "Fishbait", 36 | "Puddy Tat", 37 | "Purrito", 38 | "Yeti", 39 | "Cindy Clawford", 40 | "Meatball", 41 | "Cheddar", 42 | "Marshmallow", 43 | "Nugget", 44 | "Ramen", 45 | "Porkchop", 46 | "Porky", 47 | "Sriracha", 48 | "Tink", 49 | "Turbo", 50 | "Rambo", 51 | "Twinky", 52 | "Frodo", 53 | "Burrito", 54 | "Bacon", 55 | "Muffin", 56 | "Hobbes", 57 | "Quimby", 58 | "Ricky Ticky Tabby", 59 | "Boots", 60 | "Buttons", 61 | "Bubbles", 62 | "Cha Cha", 63 | "Cheerio", 64 | "Baloo", 65 | "Jelly", 66 | "Opie", 67 | "Stitch", 68 | "Wasabi", 69 | "Sushi", 70 | "Seuss", 71 | "Kermit", 72 | "Miss Piggy", 73 | "Pikachu", 74 | "Catzilla", 75 | "Clawdia" 76 | ] 77 | 78 | ran = fn input -> Enum.random(input) end 79 | 80 | meerkat_count = 117 81 | now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) 82 | 83 | data = 84 | for _ <- 1..meerkat_count do 85 | %{ 86 | name: "#{ran.(name_prefix)} #{ran.(names)}", 87 | inserted_at: now, 88 | updated_at: now 89 | } 90 | end 91 | 92 | Repo.insert_all(Meerkat, data) 93 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PJUllrich/pragprog-book-tables/66e67a994a3f591606175151cccdda624ba9d7b8/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PJUllrich/pragprog-book-tables/66e67a994a3f591606175151cccdda624ba9d7b8/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /test/meow/meerkats_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Meow.MeerkatsTest do 2 | use Meow.DataCase 3 | 4 | alias Meow.Meerkats 5 | 6 | describe "meerkats" do 7 | alias Meow.Meerkats.Meerkat 8 | 9 | import Meow.MeerkatsFixtures 10 | 11 | @invalid_attrs %{name: nil} 12 | 13 | test "list_meerkats/0 returns all meerkats" do 14 | meerkat = meerkat_fixture() 15 | assert Meerkats.list_meerkats() == [meerkat] 16 | end 17 | 18 | test "get_meerkat!/1 returns the meerkat with given id" do 19 | meerkat = meerkat_fixture() 20 | assert Meerkats.get_meerkat!(meerkat.id) == meerkat 21 | end 22 | 23 | test "create_meerkat/1 with valid data creates a meerkat" do 24 | valid_attrs = %{name: "some name"} 25 | 26 | assert {:ok, %Meerkat{} = meerkat} = Meerkats.create_meerkat(valid_attrs) 27 | assert meerkat.name == "some name" 28 | end 29 | 30 | test "create_meerkat/1 with invalid data returns error changeset" do 31 | assert {:error, %Ecto.Changeset{}} = Meerkats.create_meerkat(@invalid_attrs) 32 | end 33 | 34 | test "update_meerkat/2 with valid data updates the meerkat" do 35 | meerkat = meerkat_fixture() 36 | 37 | update_attrs = %{ 38 | name: "some updated name" 39 | } 40 | 41 | assert {:ok, %Meerkat{} = meerkat} = Meerkats.update_meerkat(meerkat, update_attrs) 42 | assert meerkat.name == "some updated name" 43 | end 44 | 45 | test "update_meerkat/2 with invalid data returns error changeset" do 46 | meerkat = meerkat_fixture() 47 | assert {:error, %Ecto.Changeset{}} = Meerkats.update_meerkat(meerkat, @invalid_attrs) 48 | assert meerkat == Meerkats.get_meerkat!(meerkat.id) 49 | end 50 | 51 | test "delete_meerkat/1 deletes the meerkat" do 52 | meerkat = meerkat_fixture() 53 | assert {:ok, %Meerkat{}} = Meerkats.delete_meerkat(meerkat) 54 | assert_raise Ecto.NoResultsError, fn -> Meerkats.get_meerkat!(meerkat.id) end 55 | end 56 | 57 | test "change_meerkat/1 returns a meerkat changeset" do 58 | meerkat = meerkat_fixture() 59 | assert %Ecto.Changeset{} = Meerkats.change_meerkat(meerkat) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/meow_web/forms/sorting_form_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.Forms.SortingFormTest do 2 | use Meow.DataCase, async: true 3 | 4 | alias MeowWeb.Forms.SortingForm 5 | 6 | @default_params %{ 7 | "sort_by" => "name", 8 | "sort_dir" => "desc" 9 | } 10 | 11 | describe "parse/1" do 12 | test "parses all fields correctly" do 13 | {:ok, params} = SortingForm.parse(@default_params) 14 | assert params.sort_by == :name 15 | assert params.sort_dir == :desc 16 | end 17 | 18 | test "validates the fields" do 19 | assert {:ok, _params} = SortingForm.parse(@default_params) 20 | 21 | assert {:error, _changeset} = 22 | @default_params |> Map.merge(%{"sort_by" => "foo"}) |> SortingForm.parse() 23 | 24 | assert {:error, _changeset} = 25 | @default_params |> Map.merge(%{"sort_dir" => "foo"}) |> SortingForm.parse() 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/meow_web/live/meow_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.MeowLiveTest do 2 | use MeowWeb.ConnCase, async: true 3 | 4 | import Meow.MeerkatsFixtures 5 | 6 | describe "sorting" do 7 | setup %{conn: conn} do 8 | meerkat_1 = meerkat_fixture(%{name: "Arnold"}) 9 | meerkat_2 = meerkat_fixture(%{name: "Bertie"}) 10 | 11 | {:ok, %{conn: conn, meerkat_1: meerkat_1, meerkat_2: meerkat_2}} 12 | end 13 | 14 | test "sorts ascending by ID", %{conn: conn, meerkat_1: meerkat_1, meerkat_2: meerkat_2} do 15 | {:ok, _live, html} = 16 | live(conn, Routes.live_path(conn, MeowWeb.MeowLive, sort_by: :id, sort_dir: :asc)) 17 | 18 | assert get_row_ids(html) == [meerkat_1.id, meerkat_2.id] 19 | end 20 | 21 | test "sorts descending by ID", %{conn: conn, meerkat_1: meerkat_1, meerkat_2: meerkat_2} do 22 | {:ok, _live, html} = 23 | live(conn, Routes.live_path(conn, MeowWeb.MeowLive, sort_by: :id, sort_dir: :desc)) 24 | 25 | assert get_row_ids(html) == [meerkat_2.id, meerkat_1.id] 26 | end 27 | 28 | test "sorts ascending by Name", %{conn: conn, meerkat_1: meerkat_1, meerkat_2: meerkat_2} do 29 | {:ok, _live, html} = 30 | live(conn, Routes.live_path(conn, MeowWeb.MeowLive, sort_by: :name, sort_dir: :asc)) 31 | 32 | assert get_row_ids(html) == [meerkat_1.id, meerkat_2.id] 33 | end 34 | 35 | test "sorts descending by Name", %{conn: conn, meerkat_1: meerkat_1, meerkat_2: meerkat_2} do 36 | {:ok, _live, html} = 37 | live(conn, Routes.live_path(conn, MeowWeb.MeowLive, sort_by: :name, sort_dir: :desc)) 38 | 39 | assert get_row_ids(html) == [meerkat_2.id, meerkat_1.id] 40 | end 41 | end 42 | 43 | describe "pagination" do 44 | setup %{conn: conn} do 45 | meerkats = for _ <- 1..25, do: meerkat_fixture() 46 | 47 | {:ok, %{conn: conn, meerkats: meerkats}} 48 | end 49 | 50 | test "shows the first page by default", %{conn: conn, meerkats: meerkats} do 51 | {:ok, _live, html} = live(conn, Routes.live_path(conn, MeowWeb.MeowLive)) 52 | first_10_meerkat_ids = Enum.take(meerkats, 20) |> Enum.map(& &1.id) 53 | 54 | assert get_row_ids(html) == first_10_meerkat_ids 55 | end 56 | 57 | test "respects the limit-parameter", %{conn: conn, meerkats: meerkats} do 58 | {:ok, _live, html} = live(conn, Routes.live_path(conn, MeowWeb.MeowLive, limit: 5)) 59 | first_5_meerkat_ids = Enum.take(meerkats, 5) |> Enum.map(& &1.id) 60 | 61 | assert get_row_ids(html) == first_5_meerkat_ids 62 | end 63 | 64 | test "respects the offset-parameter", %{conn: conn, meerkats: meerkats} do 65 | {:ok, _live, html} = live(conn, Routes.live_path(conn, MeowWeb.MeowLive, offset: 20)) 66 | last_5_meerkat_ids = Enum.take(meerkats, -5) |> Enum.map(& &1.id) 67 | 68 | assert get_row_ids(html) == last_5_meerkat_ids 69 | end 70 | 71 | test "respects the combination of limit and offset parameters", %{ 72 | conn: conn, 73 | meerkats: meerkats 74 | } do 75 | {:ok, _live, html} = 76 | live(conn, Routes.live_path(conn, MeowWeb.MeowLive, limit: 4, offset: 20)) 77 | 78 | last_5_meerkat_ids = Enum.take(meerkats, -5) |> Enum.map(& &1.id) 79 | last_4_meerkat_ids_wo_last_one = Enum.drop(last_5_meerkat_ids, -1) 80 | 81 | assert get_row_ids(html) == last_4_meerkat_ids_wo_last_one 82 | end 83 | end 84 | 85 | describe "handle_params" do 86 | test "shows an error message if invalid params were given", %{conn: conn} do 87 | {:ok, _live, html} = 88 | live( 89 | conn, 90 | Routes.live_path(conn, MeowWeb.MeowLive, 91 | sort_by: :name, 92 | sort_dir: :desc, 93 | limit: 4, 94 | offset: -1 95 | ) 96 | ) 97 | 98 | assert html =~ "We are unable to show you the data as you wished" 99 | assert get_row_ids(html) == [] 100 | end 101 | end 102 | 103 | describe "filter" do 104 | setup %{conn: conn} do 105 | meerkat_1 = meerkat_fixture(%{name: "Arnold", gender: :male, weight: 100}) 106 | meerkat_2 = meerkat_fixture(%{name: "Bertie", gender: :female, weight: 200}) 107 | 108 | {:ok, %{conn: conn, meerkat_1: meerkat_1, meerkat_2: meerkat_2}} 109 | end 110 | 111 | test "filters by id", %{conn: conn, meerkat_2: meerkat_2} do 112 | {:ok, _live, html} = live(conn, Routes.live_path(conn, MeowWeb.MeowLive, id: meerkat_2.id)) 113 | 114 | assert get_row_ids(html) == [meerkat_2.id] 115 | end 116 | 117 | test "filters by name", %{conn: conn, meerkat_2: meerkat_2} do 118 | {:ok, _live, html} = live(conn, Routes.live_path(conn, MeowWeb.MeowLive, name: "rti")) 119 | 120 | assert get_row_ids(html) == [meerkat_2.id] 121 | end 122 | end 123 | 124 | defp get_row_ids(html) do 125 | html 126 | |> Floki.parse_document!() 127 | |> Floki.find("tbody > tr") 128 | |> Floki.attribute("data-test-id") 129 | |> Enum.map(&String.to_integer/1) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/meow_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.ErrorViewTest do 2 | use MeowWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(MeowWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(MeowWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/meow_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.LayoutViewTest do 2 | use MeowWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /test/meow_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.PageViewTest do 2 | use MeowWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/meow_web/views/sorting_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.SortingViewTest do 2 | use MeowWeb.ConnCase, async: true 3 | 4 | import Phoenix.HTML 5 | 6 | alias MeowWeb.SortingView 7 | 8 | describe "sorting_link" do 9 | test "generates a link for sorting by a column ascending", %{conn: conn} do 10 | dom = 11 | SortingView.sorting_link(conn, %{sort_by: :id, sort_dir: :asc}, :name, do: nil) 12 | |> safe_to_string() 13 | 14 | assert dom =~ ~s|href="/?sort_by=name&sort_dir=asc"| 15 | end 16 | 17 | test "sorts descending if sort_by is already set", %{conn: conn} do 18 | dom = 19 | SortingView.sorting_link(conn, %{sort_by: :name, sort_dir: :asc}, :name, do: nil) 20 | |> safe_to_string() 21 | 22 | assert dom =~ ~s|href="/?sort_by=name&sort_dir=desc"| 23 | end 24 | 25 | test "falls back to the default sorting if a column is sorted descendingly already", %{ 26 | conn: conn 27 | } do 28 | dom = 29 | SortingView.sorting_link(conn, %{sort_by: :name, sort_dir: :desc}, :name, do: nil) 30 | |> safe_to_string() 31 | 32 | assert dom =~ ~s|href="/?sort_by=id&sort_dir=asc"| 33 | end 34 | end 35 | 36 | describe "sorting_column_title" do 37 | test "returns an ascending chevron if sorted ascending by the current key" do 38 | assert SortingView.sorting_column_title(%{sort_by: :name, sort_dir: :asc}, :name) == 39 | "Name 🔼" 40 | end 41 | 42 | test "returns an descending chevron if sorted descending by the current key" do 43 | assert SortingView.sorting_column_title(%{sort_by: :name, sort_dir: :desc}, :name) == 44 | "Name 🔽" 45 | end 46 | 47 | test "returns an empty string if not sorteg by the current key" do 48 | assert SortingView.sorting_column_title(%{sort_by: :id, sort_dir: :asc}, :name) == "Name " 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use MeowWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import MeowWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint MeowWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Meow.Repo, shared: not tags[:async]) 33 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MeowWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use MeowWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import MeowWeb.ConnCase 26 | import Phoenix.LiveViewTest 27 | 28 | alias MeowWeb.Router.Helpers, as: Routes 29 | 30 | # The default endpoint for testing 31 | @endpoint MeowWeb.Endpoint 32 | end 33 | end 34 | 35 | setup tags do 36 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Meow.Repo, shared: not tags[:async]) 37 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 38 | {:ok, conn: Phoenix.ConnTest.build_conn()} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Meow.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Meow.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Meow.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Meow.Repo, shared: not tags[:async]) 32 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 33 | :ok 34 | end 35 | 36 | @doc """ 37 | A helper that transforms changeset errors into a map of messages. 38 | 39 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 40 | assert "password is too short" in errors_on(changeset).password 41 | assert %{password: ["password is too short"]} = errors_on(changeset) 42 | 43 | """ 44 | def errors_on(changeset) do 45 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 46 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 47 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 48 | end) 49 | end) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/fixtures/meerkats_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Meow.MeerkatsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Meow.Meerkats` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a meerkat. 9 | """ 10 | def meerkat_fixture(attrs \\ %{}) do 11 | {:ok, meerkat} = 12 | attrs 13 | |> Enum.into(%{ 14 | name: "some name" 15 | }) 16 | |> Meow.Meerkats.create_meerkat() 17 | 18 | meerkat 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Meow.Repo, :manual) 3 | --------------------------------------------------------------------------------