Compare commits
	
		
			140 Commits
		
	
	
		
			0.8.2
			...
			0cae7c2940
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 0cae7c2940 | |||
| 1e645b5bb8 | |||
| bab2b26c13 | |||
| 8c95536ffd | |||
| d9251c7e4c | |||
| fe4e4f4f17 | |||
| e5e5449e8b | |||
| 355752598c | |||
| 03f8a2e8a7 | |||
| 071eb1b3c9 | |||
| 2987e4ff37 | |||
| ca81924ebe | |||
| 40e4f6fe0a | |||
| 213dcca973 | |||
| b32edd581d | |||
| 2e372ca2ab | |||
| fd0bac3bbf | |||
| f83fbc5d99 | |||
| daab051026 | |||
| 440dc5061b | |||
| c0d2c69144 | |||
| 7a7359fa66 | |||
| 9e8fd00d65 | |||
| f5f72b53e6 | |||
| a54cf8b87d | |||
| 0b7146ba32 | |||
| c0441957b6 | |||
| 7fa9933a9b | |||
| f4c7f22460 | |||
| a01d97e360 | |||
|  | a53b352cf7 | ||
| ce07cc2569 | |||
| 3acecb9a93 | |||
| ab8561fcf0 | |||
| 8163b906a2 | |||
| b29a5cce7b | |||
| 9205a04ac5 | |||
| 632a9e1379 | |||
| 92cc49630d | |||
| a778f5a61f | |||
| 07ff796553 | |||
| 07b31fcc86 | |||
| bc034c0361 | |||
| eb9280fa7e | |||
| ad1e44fd42 | |||
| 4e9f66f006 | |||
| 81350f9898 | |||
| 9c4a32896f | |||
| 56dae6cdfe | |||
| 8ef3bd65a3 | |||
| c8cadd6246 | |||
| 3f143262d4 | |||
| ebe09bcf84 | |||
| fd0b2c455a | |||
| f1139d0ec4 | |||
| cd6bb6fbc3 | |||
| 5c05f3b6fe | |||
| 30d3f76fe1 | |||
| ed8c20e967 | |||
| 7b60938a75 | |||
| f19d024d8a | |||
| 1fbed50b0f | |||
| 737484c36e | |||
| 6dbadc58ae | |||
| f155a43ee8 | |||
| bbaa1dfd6b | |||
| 2c2b9fefc9 | |||
| bafc824a32 | |||
| 8c2f7e0509 | |||
| 22abc7a8d0 | |||
| 5a685ac00e | |||
| a5c12b3e17 | |||
| 27af5acf8b | |||
| 469428c007 | |||
| 1cd28e43b8 | |||
| 695002c9d9 | |||
| dde60d71d1 | |||
| 91794ddc55 | |||
| 1e3cec95fe | |||
| f0a8c515f9 | |||
| e99775eef2 | |||
| 6760f83ca0 | |||
| 10877bb754 | |||
| 38a581b639 | |||
| 9408705430 | |||
| 302aa7eeda | |||
| fd4fdcc36b | |||
| 3cb723b9e4 | |||
| 1f92c452d1 | |||
| c10cff63ea | |||
| 70faed71d0 | |||
| b5c46c09ec | |||
| 7745765fc0 | |||
| e16fbba810 | |||
| e35bdf101b | |||
| aa314e5ca1 | |||
| 616de3c117 | |||
| 74bcec6cfe | |||
| 41090c46d0 | |||
| c3f5744ad6 | |||
| 95a339fe02 | |||
| 1e3b027367 | |||
| dd46e1795f | |||
| 9e517e6477 | |||
| 34118299e9 | |||
| 97a9b6d51a | |||
| 059004ba78 | |||
| 5d02ed6369 | |||
| ec6acdbb5d | |||
| 33d82a902d | |||
| fc5b03d680 | |||
| c918dbe4bf | |||
| 23b60e032d | |||
| 7283932d85 | |||
| 8ff1fd0276 | |||
| 4ef09f5279 | |||
| 9734be4966 | |||
| 485965d9c9 | |||
| e9cdb0f717 | |||
| 1c07449b54 | |||
| b64e85f65c | |||
| 9387756109 | |||
| f1f3082368 | |||
| 50a8a79596 | |||
| 67b48e1a3f | |||
| 67c30d7f88 | |||
| 728728a5a4 | |||
| a64d92a6cf | |||
| 6227d64072 | |||
| 46eed25a94 | |||
| 3674eeaf5a | |||
| f0676a2433 | |||
| a72a4b0cbe | |||
| a2dea04668 | |||
| 3dc255b7c2 | |||
| bde1cff7a4 | |||
| 66cc11e9eb | |||
| 81a250206e | |||
| 6d5f7f68df | |||
| 5b5f1ce1e5 | 
							
								
								
									
										19
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -13,20 +13,24 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| - name: test | ||||
|   image: elixir:1.14.1-alpine | ||||
|   environment: | ||||
|     TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test | ||||
|     HOST: testing.example.tld | ||||
|     MIX_HOME: /drone/src/.mix | ||||
|     MIX_ARCHIVES: /drone/src/.mix/archives | ||||
|     MIX_ENV: test | ||||
|   commands: | ||||
|   - apk add --no-cache build-base npm git python3 | ||||
|   - mix local.rebar --force | ||||
|   - mix local.hex --force | ||||
|   - apk add --no-cache build-base npm git | ||||
|   - mix local.rebar --force --if-missing | ||||
|   - mix local.hex --force --if-missing | ||||
|   - mix deps.get | ||||
|   - mix deps.compile | ||||
|   - npm --prefix ./assets ci --progress=false --no-audit --loglevel=error | ||||
|   - npm set cache .npm | ||||
|   - npm --prefix ./assets ci --no-audit --prefer-offline | ||||
|   - npm run --prefix ./assets deploy | ||||
|   - mix do phx.digest, gettext.extract | ||||
|   - mix test.all | ||||
| @@ -76,7 +80,8 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| services: | ||||
| - name: database | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -25,7 +25,7 @@ cannery-*.tar | ||||
| # If NPM crashes, it generates a log, let's ignore it too. | ||||
| npm-debug.log | ||||
|  | ||||
| # Ignore assets that are produced by build tools. | ||||
| # The directory NPM downloads your dependencies sources to. | ||||
| /assets/node_modules/ | ||||
|  | ||||
| # Since we are building assets from assets/, | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| elixir 1.14.1-otp-25 | ||||
| erlang 25.1.2 | ||||
| nodejs 18.12.1 | ||||
| nodejs 18.9.1 | ||||
|   | ||||
							
								
								
									
										41
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,3 +1,44 @@ | ||||
| # v0.9.1 | ||||
| - Rename ammo_type's type to class to avoid confusion | ||||
| - Code quality improvements | ||||
|  | ||||
| # v0.9.0 | ||||
| - Add length limits to all string fields | ||||
| - Add selectable ammo types | ||||
| - Improve onboarding experience slightly | ||||
| - Remove show used view from a container since it doesn't really make that much | ||||
|   sense | ||||
|  | ||||
| # v0.8.6 | ||||
| - Fix duplicate entries showing up | ||||
| - Show ammo packs under a type in a table by default | ||||
| - Only show historical ammo type information when displaying "Show used" in table | ||||
| - Only show historical ammo pack information when displaying "Show used" in table | ||||
| - Fix some values not being sorted in tables properly | ||||
| - Code quality improvements | ||||
| - Show link to ammo pack in ammo pack table while viewing ammo type | ||||
|  | ||||
| # v0.8.5 | ||||
| - Add link in readme to github mirror | ||||
| - Fix tables unable to sort on empty dates | ||||
| - Only show historical ammo type information when displaying "Show used" | ||||
| - Fix even more accessibility issues | ||||
|  | ||||
| # v0.8.4 | ||||
| - Improve accessibility | ||||
| - Code quality improvements | ||||
| - Fix dead link of example bullet abbreviations | ||||
| - Fix inaccurate error message when updating shot records | ||||
| - Fix tables not sorting dates correctly | ||||
| - Fix dates displaying incorrectly | ||||
| - Fix container table not displaying all fields | ||||
| - Fix textareas resizing when typing in them | ||||
|  | ||||
| # v0.8.3 | ||||
| - Improve some styles | ||||
| - Improve server log | ||||
| - Various minor improvements | ||||
|  | ||||
| # v0.8.2 | ||||
| - Fix bug with public registration | ||||
| - Improve templates | ||||
|   | ||||
| @@ -63,7 +63,8 @@ And as always, thank you! | ||||
|   [`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/). | ||||
| - `Dockerfile` and example `docker-compose.yml` | ||||
| - Automatic migrations in `MIX_ENV=prod` or Docker image | ||||
| - JS linting with [standard.js](https://standardjs.com) | ||||
| - JS linting with [standard.js](https://standardjs.com), HEEx linting with | ||||
|   [heex_formatter](https://github.com/feliperenan/heex_formatter) | ||||
|  | ||||
| ## Docs | ||||
|  | ||||
| @@ -109,7 +110,7 @@ In `dev` mode, Cannery will listen for these environment variables at runtime. | ||||
| - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. | ||||
| - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. | ||||
| - `LOCALE`: Sets a custom default locale. Defaults to `en_US`. | ||||
|   - Available options: `en_US`, `de`, and `fr` | ||||
|   - Available options: `en_US`, `de`, `fr`, and `es` | ||||
|  | ||||
| ## `MIX_ENV=test` | ||||
|  | ||||
|   | ||||
							
								
								
									
										11
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								README.md
									
									
									
									
									
								
							| @@ -64,7 +64,7 @@ You can use the following environment variables to configure Cannery in | ||||
| - `REGISTRATION`: Controls if user sign-up should be invite only or set to | ||||
|   public. Set to `public` to enable public registration. Defaults to `invite`. | ||||
| - `LOCALE`: Sets a custom default locale. Defaults to `en_US` | ||||
|   - Available options: `en_US`, `de`, and `fr` | ||||
|   - Available options: `en_US`, `de`, `fr` and `es` | ||||
| - `SMTP_HOST`: The url for your SMTP email provider. Must be set | ||||
| - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. | ||||
| - `SMTP_USERNAME`: The username for your SMTP relay. Must be set! | ||||
| @@ -92,6 +92,15 @@ Cannery is licensed under AGPLv3 or later. A copy of the latest version of the | ||||
| license can be found at | ||||
| [LICENSE.md](https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/LICENSE.md). | ||||
|  | ||||
| # Links | ||||
|  | ||||
| - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature | ||||
|   requests and bug reports | ||||
| - [Github](https://github.com/shibaobun/cannery): Source code mirror, please | ||||
|   don't open pull requests to this repository | ||||
| - [Weblate](https://weblate.bubbletea.dev/engage/cannery): Contribute to | ||||
|   translations! | ||||
|  | ||||
| --- | ||||
|  | ||||
| [![Build | ||||
|   | ||||
| @@ -25,7 +25,6 @@ | ||||
|   } | ||||
|  | ||||
|   .btn { | ||||
|     @apply inline-block break-words; | ||||
|     @apply focus:outline-none px-4 py-2 rounded-lg; | ||||
|     @apply shadow-sm focus:shadow-lg; | ||||
|     @apply transition-all duration-300 ease-in-out; | ||||
| @@ -52,7 +51,6 @@ | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     @apply inline-block break-words; | ||||
|     @apply hover:underline; | ||||
|     @apply transition-colors duration-500 ease-in-out; | ||||
|   } | ||||
|   | ||||
| @@ -27,23 +27,15 @@ import { LiveSocket } from 'phoenix_live_view' | ||||
| import topbar from 'topbar' | ||||
| import MaintainAttrs from './maintain_attrs' | ||||
| import ShotLogChart from './shot_log_chart' | ||||
| import Alpine from 'alpinejs' | ||||
| import Date from './date' | ||||
| import DateTime from './datetime' | ||||
|  | ||||
| const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') | ||||
| const liveSocket = new LiveSocket('/live', Socket, { | ||||
|   dom: { | ||||
|     onBeforeElUpdated (from, to) { | ||||
|       if (from._x_dataStack) { window.Alpine.clone(from, to) } | ||||
|     } | ||||
|   }, | ||||
|   params: { _csrf_token: csrfToken }, | ||||
|   hooks: { MaintainAttrs, ShotLogChart } | ||||
|   hooks: { Date, DateTime, MaintainAttrs, ShotLogChart } | ||||
| }) | ||||
|  | ||||
| // alpine.js | ||||
| window.Alpine = Alpine | ||||
| Alpine.start() | ||||
|  | ||||
| // Show progress bar on live navigation and form submits | ||||
| topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) | ||||
| window.addEventListener('phx:page-loading-start', info => topbar.show()) | ||||
|   | ||||
							
								
								
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export default { | ||||
|   displayDate (el) { | ||||
|     const date = | ||||
|       Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }) | ||||
|         .format(new Date(el.dateTime)) | ||||
|  | ||||
|     el.innerText = date | ||||
|   }, | ||||
|   mounted () { this.displayDate(this.el) }, | ||||
|   updated () { this.displayDate(this.el) } | ||||
| } | ||||
							
								
								
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export default { | ||||
|   displayDateTime (el) { | ||||
|     const date = | ||||
|       Intl.DateTimeFormat([], { dateStyle: 'short', timeStyle: 'long' }) | ||||
|         .format(new Date(el.dateTime)) | ||||
|  | ||||
|     el.innerText = date | ||||
|   }, | ||||
|   mounted () { this.displayDateTime(this.el) }, | ||||
|   updated () { this.displayDateTime(this.el) } | ||||
| } | ||||
							
								
								
									
										9280
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9280
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,8 +3,8 @@ | ||||
|   "description": " ", | ||||
|   "license": "MIT", | ||||
|   "engines": { | ||||
|     "node": "18.12.1", | ||||
|     "npm": "8.19.2" | ||||
|     "node": "v18.9.1", | ||||
|     "npm": "8.19.1" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "deploy": "NODE_ENV=production webpack --mode production", | ||||
| @@ -13,37 +13,37 @@ | ||||
|     "test": "standard" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fortawesome/fontawesome-free": "^6.1.1", | ||||
|     "alpinejs": "^3.10.2", | ||||
|     "chart.js": "^3.9.1", | ||||
|     "chartjs-adapter-date-fns": "^2.0.0", | ||||
|     "@fortawesome/fontawesome-free": "^6.3.0", | ||||
|     "chart.js": "^4.2.1", | ||||
|     "chartjs-adapter-date-fns": "^3.0.0", | ||||
|     "date-fns": "^2.29.3", | ||||
|     "phoenix": "file:../deps/phoenix", | ||||
|     "phoenix_html": "file:../deps/phoenix_html", | ||||
|     "phoenix_live_view": "file:../deps/phoenix_live_view", | ||||
|     "topbar": "^1.0.1" | ||||
|     "topbar": "^2.0.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.17.10", | ||||
|     "@babel/preset-env": "^7.17.10", | ||||
|     "autoprefixer": "^10.4.7", | ||||
|     "babel-loader": "^8.2.5", | ||||
|     "copy-webpack-plugin": "^10.2.4", | ||||
|     "css-loader": "^6.7.1", | ||||
|     "css-minimizer-webpack-plugin": "^3.4.1", | ||||
|     "@babel/core": "^7.21.3", | ||||
|     "@babel/preset-env": "^7.20.2", | ||||
|     "autoprefixer": "^10.4.14", | ||||
|     "babel-loader": "^9.1.2", | ||||
|     "copy-webpack-plugin": "^11.0.0", | ||||
|     "css-loader": "^6.7.3", | ||||
|     "css-minimizer-webpack-plugin": "^4.2.2", | ||||
|     "file-loader": "^6.2.0", | ||||
|     "mini-css-extract-plugin": "^2.6.0", | ||||
|     "postcss": "^8.4.13", | ||||
|     "postcss-import": "^14.1.0", | ||||
|     "postcss-loader": "^6.2.1", | ||||
|     "postcss-preset-env": "^7.5.0", | ||||
|     "sass": "^1.56.0", | ||||
|     "sass-loader": "^12.6.0", | ||||
|     "mini-css-extract-plugin": "^2.7.5", | ||||
|     "npm-check-updates": "^16.7.12", | ||||
|     "postcss": "^8.4.21", | ||||
|     "postcss-import": "^15.1.0", | ||||
|     "postcss-loader": "^7.1.0", | ||||
|     "postcss-preset-env": "^8.0.1", | ||||
|     "sass": "^1.59.3", | ||||
|     "sass-loader": "^13.2.1", | ||||
|     "standard": "^17.0.0", | ||||
|     "tailwindcss": "^3.0.24", | ||||
|     "terser-webpack-plugin": "^5.3.1", | ||||
|     "webpack": "^5.72.0", | ||||
|     "webpack-cli": "^4.9.2", | ||||
|     "webpack-dev-server": "^4.9.0" | ||||
|     "tailwindcss": "^3.2.7", | ||||
|     "terser-webpack-plugin": "^5.3.7", | ||||
|     "webpack": "^5.76.2", | ||||
|     "webpack-cli": "^5.0.1", | ||||
|     "webpack-dev-server": "^4.13.1" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -45,7 +45,7 @@ module.exports = (env, options) => { | ||||
|         { | ||||
|           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, | ||||
|           type: 'asset/resource', | ||||
|           generator: { filename: 'fonts/[name][ext]' } | ||||
|           generator: { filename: 'fonts/[name].[ext]' } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|   | ||||
| @@ -16,9 +16,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- get_user_by_email("foo@example.com"), do: :passed | ||||
|       :passed | ||||
|       iex> get_user_by_email("foo@example.com") | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_email("unknown@example.com") | ||||
|       nil | ||||
| @@ -34,9 +33,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- get_user_by_email_and_password("foo@example.com", "valid_password"), do: :passed | ||||
|       :passed | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "valid_password") | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") | ||||
|       nil | ||||
| @@ -57,15 +55,14 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> get_user!(user.id) | ||||
|       iex> get_user!(user_id) | ||||
|       user | ||||
|  | ||||
|       > get_user!() | ||||
|       iex> get_user!() | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_user!(User.t()) :: User.t() | ||||
|   @spec get_user!(User.id()) :: User.t() | ||||
|   def get_user!(id) do | ||||
|     Repo.get!(User, id) | ||||
|   end | ||||
| @@ -75,10 +72,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user1} = register_user(%{email: "foo1@example.com", password: "valid_password"}) | ||||
|       iex> {:ok, user2} = register_user(%{email: "foo2@example.com", password: "valid_password"}) | ||||
|       iex> with %{admin: [^user1], user: [^user2]} <- list_all_users_by_role(user1), do: :passed | ||||
|       :passed | ||||
|       iex> list_all_users_by_role(user1) | ||||
|       %{admin: [%User{role: :admin}], user: [%User{role: :user}]} | ||||
|  | ||||
|   """ | ||||
|   @spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]} | ||||
| @@ -91,9 +86,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with [^user] <- list_users_by_role(:admin), do: :passed | ||||
|       :passed | ||||
|       iex> list_users_by_role(:admin) | ||||
|       [%User{role: :admin}] | ||||
|  | ||||
|   """ | ||||
|   @spec list_users_by_role(:admin) :: [User.t()] | ||||
| @@ -108,13 +102,11 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> with {:ok, %User{email: "foo@example.com"}} <- | ||||
|       ...>        register_user(%{email: "foo@example.com", password: "valid_password"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       {:ok, %User{email: "foo@example.com"}} | ||||
|  | ||||
|       iex> with {:error, %Changeset{}} <- register_user(%{email: "foo@example"}), do: :passed | ||||
|       :passed | ||||
|       iex> register_user(%{email: "foo@example"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec register_user(attrs :: map()) :: | ||||
| @@ -149,11 +141,11 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_registration(), do: :passed | ||||
|       :passed | ||||
|       iex> change_user_registration() | ||||
|       %Changeset{} | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_registration(%{password: "hi"}), do: :passed | ||||
|       :passed | ||||
|       iex> change_user_registration(%{password: "hi"} | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_registration() :: User.changeset() | ||||
| @@ -169,8 +161,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_email(%User{email: "foo@example.com"}), do: :passed | ||||
|       :passed | ||||
|       iex> change_user_email(%User{email: "foo@example.com"}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_email(User.t()) :: User.changeset() | ||||
| @@ -184,8 +176,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_role(%User{}, :user), do: :passed | ||||
|       :passed | ||||
|       iex> change_user_role(%User{}, :user) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_role(User.t(), User.role()) :: User.changeset() | ||||
| @@ -199,17 +191,11 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- | ||||
|       ...>        apply_user_email(user, "valid_password", %{email: "new_email@account.com"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"}) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:error, %Changeset{}} <- | ||||
|       ...>        apply_user_email(user, "invalid password", %{email: "new_email@account"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       iex> apply_user_email(user, "invalid password", %{email: "new_email@account"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) :: | ||||
| @@ -254,12 +240,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, %{id: user_id} = user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %Oban.Job{ | ||||
|       ...>        args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}} | ||||
|       ...>      } <- deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end) | ||||
|       %Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) :: | ||||
| @@ -276,8 +258,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_password(%User{}), do: :passed | ||||
|       :passed | ||||
|       iex> change_user_password(%User{}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_password(User.t(), attrs :: map()) :: User.changeset() | ||||
| @@ -290,20 +272,14 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- | ||||
|       ...>         reset_user_password(user, %{ | ||||
|       iex> reset_user_password(user, %{ | ||||
|       ...>   password: "new password", | ||||
|       ...>   password_confirmation: "new password" | ||||
|       ...>         }), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       ...> }) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:error, %Changeset{}} <- | ||||
|       ...>        update_user_password(user, "invalid password", %{password: "123"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       iex> update_user_password(user, "invalid password", %{password: "123"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_password(User.t(), String.t(), attrs :: map()) :: | ||||
| @@ -329,8 +305,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> with %Changeset{} <- change_user_locale(%User{}), do: :passed | ||||
|       :passed | ||||
|       iex> change_user_locale(%User{}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_locale(User.t()) :: User.changeset() | ||||
| @@ -343,9 +319,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- update_user_locale(user, "en_US"), do: :passed | ||||
|       :passed | ||||
|       iex> update_user_locale(user, "en_US") | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_locale(User.t(), locale :: String.t()) :: | ||||
| @@ -359,13 +334,11 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- delete_user!(user, %User{id: 123, role: :admin}), do: :passed | ||||
|       :passed | ||||
|       iex> delete_user!(user, %User{id: 123, role: :admin}) | ||||
|       %User{} | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %User{} <- delete_user!(user, user), do: :passed | ||||
|       :passed | ||||
|       iex> delete_user!(user, user) | ||||
|       %User{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t() | ||||
| @@ -412,8 +385,18 @@ defmodule Cannery.Accounts do | ||||
|   """ | ||||
|   @spec allow_registration?() :: boolean() | ||||
|   def allow_registration? do | ||||
|     Application.get_env(:cannery, Cannery.Accounts)[:registration] == "public" or | ||||
|       list_users_by_role(:admin) |> Enum.empty?() | ||||
|     registration_mode() == :public or list_users_by_role(:admin) |> Enum.empty?() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an atom representing the current configured registration mode | ||||
|   """ | ||||
|   @spec registration_mode() :: :public | :invite_only | ||||
|   def registration_mode do | ||||
|     case Application.get_env(:cannery, Cannery.Accounts)[:registration] do | ||||
|       "public" -> :public | ||||
|       _other -> :invite_only | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -421,11 +404,10 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> is_admin?(user) | ||||
|       iex> is_admin?(%User{role: :admin}) | ||||
|       true | ||||
|  | ||||
|       iex> is_admin?(%User{id: Ecto.UUID.generate()}) | ||||
|       iex> is_admin?(%User{}) | ||||
|       false | ||||
|  | ||||
|   """ | ||||
| @@ -439,8 +421,7 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> is_already_admin?(user) | ||||
|       iex> is_already_admin?(%User{role: :admin}) | ||||
|       true | ||||
|  | ||||
|       iex> is_already_admin?(%User{}) | ||||
| @@ -458,15 +439,9 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, %{id: user_id} = user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %Oban.Job{ | ||||
|       ...>   args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}} | ||||
|       ...> } <- deliver_user_confirmation_instructions(user, fn _token -> "example url" end), | ||||
|       ...> do: :passed | ||||
|       :passed | ||||
|       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||
|       %Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> user = user |> User.confirm_changeset() |> Repo.update!() | ||||
|       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||
|       {:error, :already_confirmed} | ||||
|  | ||||
| @@ -514,12 +489,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, %{id: user_id} = user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with %Oban.Job{args: %{ | ||||
|       ...>        email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}} | ||||
|       ...>    } <- deliver_user_reset_password_instructions(user, fn _token -> "example url" end), | ||||
|       ...>    do: :passed | ||||
|       :passed | ||||
|       iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end) | ||||
|       %Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() | ||||
| @@ -535,11 +506,8 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") | ||||
|       iex> Repo.insert!(user_token) | ||||
|       iex> with %User{} <- get_user_by_reset_password_token(encoded_token), do: :passed | ||||
|       :passed | ||||
|       iex> get_user_by_reset_password_token(encoded_token) | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("invalidtoken") | ||||
|       nil | ||||
| @@ -560,20 +528,14 @@ defmodule Cannery.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:ok, %User{}} <- | ||||
|       ...>         reset_user_password(user, %{ | ||||
|       iex> reset_user_password(user, %{ | ||||
|       ...>   password: "new password", | ||||
|       ...>   password_confirmation: "new password" | ||||
|       ...>         }), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       ...> }) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> {:ok, user} = register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       iex> with {:error, %Changeset{}} <- | ||||
|       ...>        reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}), | ||||
|       ...>      do: :passed | ||||
|       :passed | ||||
|       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec reset_user_password(User.t(), attrs :: map()) :: | ||||
|   | ||||
| @@ -27,21 +27,21 @@ defmodule Cannery.Email do | ||||
|   @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() | ||||
|   def generate_email("welcome", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Confirm your %{name} account", name: "Cannery")) | ||||
|     |> base_email(dgettext("emails", "Confirm your Cannery account")) | ||||
|     |> render_body("confirm_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) | ||||
|   end | ||||
|  | ||||
|   def generate_email("reset_password", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Reset your %{name} password", name: "Cannery")) | ||||
|     |> base_email(dgettext("emails", "Reset your Cannery password")) | ||||
|     |> render_body("reset_password.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) | ||||
|   end | ||||
|  | ||||
|   def generate_email("update_email", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Update your %{name} email", name: "Cannery")) | ||||
|     |> base_email(dgettext("emails", "Update your Cannery email")) | ||||
|     |> render_body("update_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) | ||||
|   end | ||||
|   | ||||
| @@ -48,8 +48,9 @@ defmodule Cannery.Accounts.Invite do | ||||
|     %__MODULE__{} | ||||
|     |> change(token: token, created_by_id: user_id) | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :created_by_id]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|     |> validate_required([:name, :token, :created_by_id]) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
| @@ -57,7 +58,8 @@ defmodule Cannery.Accounts.Invite do | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|     |> validate_required([:name]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -100,13 +100,23 @@ defmodule Cannery.Accounts.Invites do | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | ||||
|   def get_use_count(%Invite{id: invite_id}, %User{role: :admin}) do | ||||
|     Repo.one( | ||||
|   @spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | nil | ||||
|   def get_use_count(%Invite{id: invite_id} = invite, user) do | ||||
|     [invite] |> get_use_counts(user) |> Map.get(invite_id) | ||||
|   end | ||||
|  | ||||
|   @spec get_use_counts([Invite.t()], User.t()) :: | ||||
|           %{optional(Invite.id()) => non_neg_integer()} | ||||
|   def get_use_counts(invites, %User{role: :admin}) do | ||||
|     invite_ids = invites |> Enum.map(fn %{id: invite_id} -> invite_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from u in User, | ||||
|         where: u.invite_id == ^invite_id, | ||||
|         select: count(u.id) | ||||
|         where: u.invite_id in ^invite_ids, | ||||
|         group_by: u.invite_id, | ||||
|         select: {u.invite_id, count(u.id)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @spec decrement_invite_changeset(Invite.t()) :: Invite.changeset() | ||||
|   | ||||
| @@ -79,6 +79,7 @@ defmodule Cannery.Accounts.User do | ||||
|     %User{} | ||||
|     |> cast(attrs, [:email, :password, :locale]) | ||||
|     |> put_change(:invite_id, if(invite, do: invite.id)) | ||||
|     |> validate_length(:locale, max: 255) | ||||
|     |> validate_email() | ||||
|     |> validate_password(opts) | ||||
|   end | ||||
| @@ -209,6 +210,7 @@ defmodule Cannery.Accounts.User do | ||||
|   def locale_changeset(user_or_changeset, locale) do | ||||
|     user_or_changeset | ||||
|     |> cast(%{"locale" => locale}, [:locale]) | ||||
|     |> validate_length(:locale, max: 255) | ||||
|     |> validate_required(:locale) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -4,37 +4,55 @@ defmodule Cannery.ActivityLog do | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} | ||||
|   alias Ecto.Multi | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo} | ||||
|   alias Ecto.{Multi, Queryable} | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of shot_groups. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_shot_groups(%User{id: 123}) | ||||
|       iex> list_shot_groups(:all, %User{id: 123}) | ||||
|       [%ShotGroup{}, ...] | ||||
|  | ||||
|       iex> list_shot_groups("cool", %User{id: 123}) | ||||
|       iex> list_shot_groups("cool", :all, %User{id: 123}) | ||||
|       [%ShotGroup{notes: "My cool shot group"}, ...] | ||||
|  | ||||
|       iex> list_shot_groups("cool", :rifle, %User{id: 123}) | ||||
|       [%ShotGroup{notes: "Shot some rifle rounds"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_shot_groups(User.t()) :: [ShotGroup.t()] | ||||
|   @spec list_shot_groups(search :: nil | String.t(), User.t()) :: [ShotGroup.t()] | ||||
|   def list_shot_groups(search \\ nil, user) | ||||
|   @spec list_shot_groups(AmmoType.class() | :all, User.t()) :: [ShotGroup.t()] | ||||
|   @spec list_shot_groups(search :: nil | String.t(), AmmoType.class() | :all, User.t()) :: | ||||
|           [ShotGroup.t()] | ||||
|   def list_shot_groups(search \\ nil, type, %{id: user_id}) do | ||||
|     from(sg in ShotGroup, | ||||
|       as: :sg, | ||||
|       left_join: ag in AmmoGroup, | ||||
|       as: :ag, | ||||
|       on: sg.ammo_group_id == ag.id, | ||||
|       left_join: at in AmmoType, | ||||
|       as: :at, | ||||
|       on: ag.ammo_type_id == at.id, | ||||
|       where: sg.user_id == ^user_id, | ||||
|       distinct: sg.id | ||||
|     ) | ||||
|     |> list_shot_groups_search(search) | ||||
|     |> list_shot_groups_filter_type(type) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   def list_shot_groups(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from sg in ShotGroup, where: sg.user_id == ^user_id) | ||||
|   @spec list_shot_groups_search(Queryable.t(), search :: String.t() | nil) :: | ||||
|           Queryable.t() | ||||
|   defp list_shot_groups_search(query, search) when search in ["", nil], do: query | ||||
|  | ||||
|   def list_shot_groups(search, %{id: user_id}) when search |> is_binary() do | ||||
|   defp list_shot_groups_search(query, search) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         left_join: ag in assoc(sg, :ammo_group), | ||||
|         left_join: at in assoc(ag, :ammo_type), | ||||
|         where: sg.user_id == ^user_id, | ||||
|         where: | ||||
|     query | ||||
|     |> where( | ||||
|       [sg: sg, ag: ag, at: at], | ||||
|       fragment( | ||||
|         "? @@ websearch_to_tsquery('english', ?)", | ||||
|         sg.search, | ||||
| @@ -49,15 +67,40 @@ defmodule Cannery.ActivityLog do | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           at.search, | ||||
|           ^trimmed_search | ||||
|             ), | ||||
|         order_by: { | ||||
|         ) | ||||
|     ) | ||||
|     |> order_by([sg: sg], { | ||||
|       :desc, | ||||
|       fragment( | ||||
|         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|         sg.search, | ||||
|         ^trimmed_search | ||||
|       ) | ||||
|         } | ||||
|     }) | ||||
|   end | ||||
|  | ||||
|   @spec list_shot_groups_filter_type(Queryable.t(), AmmoType.class() | :all) :: | ||||
|           Queryable.t() | ||||
|   defp list_shot_groups_filter_type(query, :rifle), | ||||
|     do: query |> where([at: at], at.class == :rifle) | ||||
|  | ||||
|   defp list_shot_groups_filter_type(query, :pistol), | ||||
|     do: query |> where([at: at], at.class == :pistol) | ||||
|  | ||||
|   defp list_shot_groups_filter_type(query, :shotgun), | ||||
|     do: query |> where([at: at], at.class == :shotgun) | ||||
|  | ||||
|   defp list_shot_groups_filter_type(query, _all), do: query | ||||
|  | ||||
|   @spec list_shot_groups_for_ammo_group(AmmoGroup.t(), User.t()) :: [ShotGroup.t()] | ||||
|   def list_shot_groups_for_ammo_group( | ||||
|         %AmmoGroup{id: ammo_group_id, user_id: user_id}, | ||||
|         %User{id: user_id} | ||||
|       ) do | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         where: sg.ammo_group_id == ^ammo_group_id, | ||||
|         where: sg.user_id == ^user_id | ||||
|     ) | ||||
|   end | ||||
|  | ||||
| @@ -107,9 +150,15 @@ defmodule Cannery.ActivityLog do | ||||
|     ) | ||||
|     |> Multi.run( | ||||
|       :ammo_group, | ||||
|       fn repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> | ||||
|         {:ok, | ||||
|          repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)} | ||||
|       fn _repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> | ||||
|         ammo_group = | ||||
|           Repo.one( | ||||
|             from ag in AmmoGroup, | ||||
|               where: ag.id == ^ammo_group_id, | ||||
|               where: ag.user_id == ^user_id | ||||
|           ) | ||||
|  | ||||
|         {:ok, ammo_group} | ||||
|       end | ||||
|     ) | ||||
|     |> Multi.update( | ||||
| @@ -220,4 +269,112 @@ defmodule Cannery.ActivityLog do | ||||
|       {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the number of shot rounds for an ammo group | ||||
|   """ | ||||
|   @spec get_used_count(AmmoGroup.t(), User.t()) :: non_neg_integer() | ||||
|   def get_used_count(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do | ||||
|     [ammo_group] | ||||
|     |> get_used_counts(user) | ||||
|     |> Map.get(ammo_group_id, 0) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the number of shot rounds for multiple ammo groups | ||||
|   """ | ||||
|   @spec get_used_counts([AmmoGroup.t()], User.t()) :: | ||||
|           %{optional(AmmoGroup.id()) => non_neg_integer()} | ||||
|   def get_used_counts(ammo_groups, %User{id: user_id}) do | ||||
|     ammo_group_ids = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %{id: ammo_group_id} -> ammo_group_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         where: sg.ammo_group_id in ^ammo_group_ids, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         group_by: sg.ammo_group_id, | ||||
|         select: {sg.ammo_group_id, sum(sg.count)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the last entered shot group date for an ammo group | ||||
|   """ | ||||
|   @spec get_last_used_date(AmmoGroup.t(), User.t()) :: Date.t() | nil | ||||
|   def get_last_used_date(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do | ||||
|     [ammo_group] | ||||
|     |> get_last_used_dates(user) | ||||
|     |> Map.get(ammo_group_id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the last entered shot group date for an ammo group | ||||
|   """ | ||||
|   @spec get_last_used_dates([AmmoGroup.t()], User.t()) :: %{optional(AmmoGroup.id()) => Date.t()} | ||||
|   def get_last_used_dates(ammo_groups, %User{id: user_id}) do | ||||
|     ammo_group_ids = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %AmmoGroup{id: ammo_group_id, user_id: ^user_id} -> ammo_group_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         where: sg.ammo_group_id in ^ammo_group_ids, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         group_by: sg.ammo_group_id, | ||||
|         select: {sg.ammo_group_id, max(sg.date)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the total number of rounds shot for an ammo type | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Ammo type does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_used_count_for_ammo_type(123, %User{id: 123}) | ||||
|       35 | ||||
|  | ||||
|       iex> get_used_count_for_ammo_type(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_used_count_for_ammo_type(AmmoType.t(), User.t()) :: non_neg_integer() | ||||
|   def get_used_count_for_ammo_type(%AmmoType{id: ammo_type_id} = ammo_type, user) do | ||||
|     [ammo_type] | ||||
|     |> get_used_count_for_ammo_types(user) | ||||
|     |> Map.get(ammo_type_id, 0) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the total number of rounds shot for multiple ammo types | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_used_count_for_ammo_types(123, %User{id: 123}) | ||||
|       35 | ||||
|  | ||||
|   """ | ||||
|   @spec get_used_count_for_ammo_types([AmmoType.t()], User.t()) :: | ||||
|           %{optional(AmmoType.id()) => non_neg_integer()} | ||||
|   def get_used_count_for_ammo_types(ammo_types, %User{id: user_id}) do | ||||
|     ammo_type_ids = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn %AmmoType{id: ammo_type_id, user_id: ^user_id} -> ammo_type_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in ShotGroup, | ||||
|         on: ag.id == sg.ammo_group_id, | ||||
|         where: ag.ammo_type_id in ^ammo_type_ids, | ||||
|         where: not (sg.count |> is_nil()), | ||||
|         group_by: ag.ammo_type_id, | ||||
|         select: {ag.ammo_type_id, sum(sg.count)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -6,7 +6,7 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|   use Ecto.Schema | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo} | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
| @@ -24,25 +24,23 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|     field :date, :date | ||||
|     field :notes, :string | ||||
|  | ||||
|     belongs_to :user, User | ||||
|     belongs_to :ammo_group, AmmoGroup | ||||
|     field :user_id, :binary_id | ||||
|     field :ammo_group_id, :binary_id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %ShotGroup{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           count: integer, | ||||
|           notes: String.t() | nil, | ||||
|           date: Date.t() | nil, | ||||
|           ammo_group: AmmoGroup.t() | nil, | ||||
|           ammo_group_id: AmmoGroup.id(), | ||||
|           user: User.t() | nil, | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_shot_group :: %ShotGroup{} | ||||
|   @type new_shot_group :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_shot_group()) | ||||
|  | ||||
| @@ -58,44 +56,52 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|         %User{id: user_id}, | ||||
|         %AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group, | ||||
|         attrs | ||||
|       ) | ||||
|       when not (user_id |> is_nil()) and not (ammo_group_id |> is_nil()) do | ||||
|       ) do | ||||
|     shot_group | ||||
|     |> change(user_id: user_id) | ||||
|     |> change(ammo_group_id: ammo_group_id) | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_length(:notes, max: 255) | ||||
|     |> validate_create_shot_group_count(ammo_group) | ||||
|     |> validate_required([:count, :date, :ammo_group_id, :user_id]) | ||||
|     |> validate_required([:date, :ammo_group_id, :user_id]) | ||||
|   end | ||||
|  | ||||
|   def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do | ||||
|     shot_group | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :ammo_group_id, :user_id]) | ||||
|     |> validate_length(:notes, max: 255) | ||||
|     |> validate_required([:ammo_group_id, :user_id]) | ||||
|     |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack")) | ||||
|   end | ||||
|  | ||||
|   defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do | ||||
|     if changeset |> Changeset.get_field(:count) > ammo_group_count do | ||||
|       error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) | ||||
|       changeset |> Changeset.add_error(:count, error) | ||||
|     else | ||||
|     case changeset |> Changeset.get_field(:count) do | ||||
|       nil -> | ||||
|         changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank")) | ||||
|  | ||||
|       count when count > ammo_group_count -> | ||||
|         changeset | ||||
|         |> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0")) | ||||
|  | ||||
|       count when count <= 0 -> | ||||
|         error = | ||||
|           dgettext("errors", "Ammo left can be at most %{count} rounds", | ||||
|             count: ammo_group_count - 1 | ||||
|           ) | ||||
|  | ||||
|         changeset |> Changeset.add_error(:ammo_left, error) | ||||
|  | ||||
|       _valid_count -> | ||||
|         changeset | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset() | ||||
|   def update_changeset( | ||||
|         %ShotGroup{user_id: user_id} = shot_group, | ||||
|         %User{id: user_id} = user, | ||||
|         attrs | ||||
|       ) | ||||
|       when not (user_id |> is_nil()) do | ||||
|   def update_changeset(%__MODULE__{} = shot_group, user, attrs) do | ||||
|     shot_group | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_length(:notes, max: 255) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :date]) | ||||
|     |> validate_update_shot_group_count(shot_group, user) | ||||
| @@ -103,25 +109,20 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|  | ||||
|   defp validate_update_shot_group_count( | ||||
|          changeset, | ||||
|          %ShotGroup{count: count} = shot_group, | ||||
|          %User{id: user_id} | ||||
|        ) | ||||
|        when not (user_id |> is_nil()) do | ||||
|     %{ammo_group: %AmmoGroup{count: ammo_group_count, user_id: ^user_id}} = | ||||
|       shot_group |> Repo.preload(:ammo_group) | ||||
|          %__MODULE__{ammo_group_id: ammo_group_id, count: count}, | ||||
|          user | ||||
|        ) do | ||||
|     %{count: ammo_group_count} = Ammo.get_ammo_group!(ammo_group_id, user) | ||||
|  | ||||
|     new_shot_group_count = changeset |> Changeset.get_field(:count) | ||||
|     shot_diff_to_add = new_shot_group_count - count | ||||
|  | ||||
|     cond do | ||||
|       shot_diff_to_add > ammo_group_count -> | ||||
|         error = dgettext("errors", "Count must be less than %{count}", count: ammo_group_count) | ||||
|     if shot_diff_to_add > ammo_group_count do | ||||
|       error = | ||||
|         dgettext("errors", "Count can be at most %{count} shots", count: ammo_group_count + count) | ||||
|  | ||||
|       changeset |> Changeset.add_error(:count, error) | ||||
|  | ||||
|       new_shot_group_count <= 0 -> | ||||
|         changeset |> Changeset.add_error(:count, dgettext("errors", "Count must be at least 1")) | ||||
|  | ||||
|       true -> | ||||
|     else | ||||
|       changeset | ||||
|     end | ||||
|   end | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -9,8 +9,8 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|   use Ecto.Schema | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Containers, Containers.Container} | ||||
|   alias Cannery.Ammo.AmmoType | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
| @@ -33,15 +33,13 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|     field :purchased_on, :date | ||||
|  | ||||
|     belongs_to :ammo_type, AmmoType | ||||
|     belongs_to :container, Container | ||||
|     belongs_to :user, User | ||||
|  | ||||
|     has_many :shot_groups, ShotGroup | ||||
|     field :container_id, :binary_id | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %AmmoGroup{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           count: integer, | ||||
|           notes: String.t() | nil, | ||||
| @@ -50,14 +48,12 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|           purchased_on: Date.t(), | ||||
|           ammo_type: AmmoType.t() | nil, | ||||
|           ammo_type_id: AmmoType.id(), | ||||
|           container: Container.t() | nil, | ||||
|           container_id: Container.id(), | ||||
|           user: User.t() | nil, | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_ammo_group :: %AmmoGroup{} | ||||
|   @type new_ammo_group :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_ammo_group()) | ||||
|  | ||||
| @@ -76,8 +72,7 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|         %User{id: user_id}, | ||||
|         attrs | ||||
|       ) | ||||
|       when not (ammo_type_id |> is_nil()) and not (container_id |> is_nil()) and | ||||
|              not (user_id |> is_nil()) do | ||||
|       when is_binary(ammo_type_id) and is_binary(container_id) and is_binary(user_id) do | ||||
|     ammo_group | ||||
|     |> change(ammo_type_id: ammo_type_id) | ||||
|     |> change(user_id: user_id) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.Accounts.User | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Cannery.Ammo.AmmoGroup | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
| @@ -42,39 +42,57 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|     field :name, :string | ||||
|     field :desc, :string | ||||
|  | ||||
|     # https://en.wikipedia.org/wiki/Bullet#Abbreviations | ||||
|     field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol] | ||||
|  | ||||
|     # common fields | ||||
|     # https://shootersreference.com/reloadingdata/bullet_abbreviations/ | ||||
|     field :bullet_type, :string | ||||
|     field :bullet_core, :string | ||||
|     field :cartridge, :string | ||||
|     # also gauge for shotguns | ||||
|     field :caliber, :string | ||||
|     field :case_material, :string | ||||
|     field :jacket_type, :string | ||||
|     field :muzzle_velocity, :integer | ||||
|     field :powder_type, :string | ||||
|     field :powder_grains_per_charge, :integer | ||||
|     field :grains, :integer | ||||
|     field :pressure, :string | ||||
|     field :primer_type, :string | ||||
|     field :firing_type, :string | ||||
|     field :manufacturer, :string | ||||
|     field :upc, :string | ||||
|  | ||||
|     field :tracer, :boolean, default: false | ||||
|     field :incendiary, :boolean, default: false | ||||
|     field :blank, :boolean, default: false | ||||
|     field :corrosive, :boolean, default: false | ||||
|  | ||||
|     field :manufacturer, :string | ||||
|     field :upc, :string | ||||
|     # rifle/pistol fields | ||||
|     field :cartridge, :string | ||||
|     field :jacket_type, :string | ||||
|     field :powder_grains_per_charge, :integer | ||||
|     field :muzzle_velocity, :integer | ||||
|  | ||||
|     belongs_to :user, User | ||||
|     # shotgun fields | ||||
|     field :wadding, :string | ||||
|     field :shot_type, :string | ||||
|     field :shot_material, :string | ||||
|     field :shot_size, :string | ||||
|     field :unfired_length, :string | ||||
|     field :brass_height, :string | ||||
|     field :chamber_size, :string | ||||
|     field :load_grains, :integer | ||||
|     field :shot_charge_weight, :string | ||||
|     field :dram_equivalent, :string | ||||
|  | ||||
|     field :user_id, :binary_id | ||||
|     has_many :ammo_groups, AmmoGroup | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %AmmoType{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           desc: String.t() | nil, | ||||
|           class: class(), | ||||
|           bullet_type: String.t() | nil, | ||||
|           bullet_core: String.t() | nil, | ||||
|           cartridge: String.t() | nil, | ||||
| @@ -88,6 +106,16 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|           pressure: String.t() | nil, | ||||
|           primer_type: String.t() | nil, | ||||
|           firing_type: String.t() | nil, | ||||
|           wadding: String.t() | nil, | ||||
|           shot_type: String.t() | nil, | ||||
|           shot_material: String.t() | nil, | ||||
|           shot_size: String.t() | nil, | ||||
|           unfired_length: String.t() | nil, | ||||
|           brass_height: String.t() | nil, | ||||
|           chamber_size: String.t() | nil, | ||||
|           load_grains: integer() | nil, | ||||
|           shot_charge_weight: String.t() | nil, | ||||
|           dram_equivalent: String.t() | nil, | ||||
|           tracer: boolean(), | ||||
|           incendiary: boolean(), | ||||
|           blank: boolean(), | ||||
| @@ -95,20 +123,21 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|           manufacturer: String.t() | nil, | ||||
|           upc: String.t() | nil, | ||||
|           user_id: User.id(), | ||||
|           user: User.t() | nil, | ||||
|           ammo_groups: [AmmoGroup.t()] | nil, | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_ammo_type :: %AmmoType{} | ||||
|   @type new_ammo_type :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_ammo_type()) | ||||
|   @type class :: :rifle | :shotgun | :pistol | nil | ||||
|  | ||||
|   @spec changeset_fields() :: [atom()] | ||||
|   defp changeset_fields, | ||||
|     do: [ | ||||
|       :name, | ||||
|       :desc, | ||||
|       :class, | ||||
|       :bullet_type, | ||||
|       :bullet_core, | ||||
|       :cartridge, | ||||
| @@ -122,6 +151,16 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|       :pressure, | ||||
|       :primer_type, | ||||
|       :firing_type, | ||||
|       :wadding, | ||||
|       :shot_type, | ||||
|       :shot_material, | ||||
|       :shot_size, | ||||
|       :unfired_length, | ||||
|       :brass_height, | ||||
|       :chamber_size, | ||||
|       :load_grains, | ||||
|       :shot_charge_weight, | ||||
|       :dram_equivalent, | ||||
|       :tracer, | ||||
|       :incendiary, | ||||
|       :blank, | ||||
| @@ -130,20 +169,55 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|       :upc | ||||
|     ] | ||||
|  | ||||
|   @spec string_fields() :: [atom()] | ||||
|   defp string_fields, | ||||
|     do: [ | ||||
|       :name, | ||||
|       :bullet_type, | ||||
|       :bullet_core, | ||||
|       :cartridge, | ||||
|       :caliber, | ||||
|       :case_material, | ||||
|       :jacket_type, | ||||
|       :powder_type, | ||||
|       :pressure, | ||||
|       :primer_type, | ||||
|       :firing_type, | ||||
|       :wadding, | ||||
|       :shot_type, | ||||
|       :shot_material, | ||||
|       :shot_size, | ||||
|       :unfired_length, | ||||
|       :brass_height, | ||||
|       :chamber_size, | ||||
|       :shot_charge_weight, | ||||
|       :dram_equivalent, | ||||
|       :manufacturer, | ||||
|       :upc | ||||
|     ] | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(ammo_type, %User{id: user_id}, attrs) do | ||||
|     changeset = | ||||
|       ammo_type | ||||
|       |> change(user_id: user_id) | ||||
|       |> cast(attrs, changeset_fields()) | ||||
|  | ||||
|     string_fields() | ||||
|     |> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end) | ||||
|     |> validate_required([:name, :user_id]) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(ammo_type, attrs) do | ||||
|     changeset = | ||||
|       ammo_type | ||||
|       |> cast(attrs, changeset_fields()) | ||||
|  | ||||
|     string_fields() | ||||
|     |> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end) | ||||
|     |> validate_required(:name) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -4,7 +4,7 @@ defmodule Cannery.Application do | ||||
|   @moduledoc false | ||||
|  | ||||
|   use Application | ||||
|   alias Cannery.ErrorReporter | ||||
|   alias Cannery.Logger | ||||
|  | ||||
|   @impl true | ||||
|   def start(_type, _args) do | ||||
| @@ -33,7 +33,7 @@ defmodule Cannery.Application do | ||||
|           [:oban, :job, :start], | ||||
|           [:oban, :job, :stop] | ||||
|         ], | ||||
|         &ErrorReporter.handle_event/4, | ||||
|         &Logger.handle_event/4, | ||||
|         [] | ||||
|       ) | ||||
|  | ||||
|   | ||||
							
								
								
									
										12
									
								
								lib/cannery/comparable_date.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/cannery/comparable_date.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| defmodule Cannery.ComparableDate do | ||||
|   @moduledoc """ | ||||
|   A custom `Date` module that provides a `compare/2` function that is comparable | ||||
|   with nil values | ||||
|   """ | ||||
|  | ||||
|   @spec compare(Date.t() | any(), Date.t() | any()) :: :lt | :gt | :eq | ||||
|   def compare(%Date{} = date_1, %Date{} = date_2), do: Date.compare(date_1, date_2) | ||||
|   def compare(%Date{}, _date_2), do: :lt | ||||
|   def compare(_date_1, %Date{}), do: :gt | ||||
|   def compare(_date_1, _date_2), do: :eq | ||||
| end | ||||
							
								
								
									
										15
									
								
								lib/cannery/comparable_datetime.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								lib/cannery/comparable_datetime.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| defmodule Cannery.ComparableDateTime do | ||||
|   @moduledoc """ | ||||
|   A custom `DateTime` module that provides a `compare/2` function that is | ||||
|   comparable with nil values | ||||
|   """ | ||||
|  | ||||
|   @spec compare(DateTime.t() | any(), DateTime.t() | any()) :: :lt | :gt | :eq | ||||
|   def compare(%DateTime{} = datetime_1, %DateTime{} = datetime_2) do | ||||
|     DateTime.compare(datetime_1, datetime_2) | ||||
|   end | ||||
|  | ||||
|   def compare(%DateTime{}, _datetime_2), do: :lt | ||||
|   def compare(_datetime_1, %DateTime{}), do: :gt | ||||
|   def compare(_datetime_1, _datetime_2), do: :eq | ||||
| end | ||||
| @@ -5,10 +5,12 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo, Tags.Tag} | ||||
|   alias Cannery.Containers.{Container, ContainerTag} | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo} | ||||
|   alias Cannery.Containers.{Container, ContainerTag, Tag} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   @container_preloads [:tags] | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of containers. | ||||
|  | ||||
| @@ -28,11 +30,10 @@ defmodule Cannery.Containers do | ||||
|       as: :c, | ||||
|       left_join: t in assoc(c, :tags), | ||||
|       as: :t, | ||||
|       left_join: ag in assoc(c, :ammo_groups), | ||||
|       as: :ag, | ||||
|       where: c.user_id == ^user_id, | ||||
|       order_by: c.name, | ||||
|       preload: [tags: t, ammo_groups: ag] | ||||
|       distinct: c.id, | ||||
|       preload: ^@container_preloads | ||||
|     ) | ||||
|     |> list_containers_search(search) | ||||
|     |> Repo.all() | ||||
| @@ -91,7 +92,7 @@ defmodule Cannery.Containers do | ||||
|   @doc """ | ||||
|   Gets a single container. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Container does not exist. | ||||
|   Raises `KeyError` if the Container does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
| @@ -99,20 +100,37 @@ defmodule Cannery.Containers do | ||||
|       %Container{} | ||||
|  | ||||
|       iex> get_container!(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|       ** (KeyError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_container!(Container.id(), User.t()) :: Container.t() | ||||
|   def get_container!(id, %User{id: user_id}) do | ||||
|     Repo.one!( | ||||
|   def get_container!(id, user) do | ||||
|     [id] | ||||
|     |> get_containers(user) | ||||
|     |> Map.fetch!(id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets multiple containers. | ||||
|  | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_containers([123], %User{id: 123}) | ||||
|       %{123 => %Container{}} | ||||
|  | ||||
|   """ | ||||
|   @spec get_containers([Container.id()], User.t()) :: %{optional(Container.id()) => Container.t()} | ||||
|   def get_containers(ids, %User{id: user_id}) do | ||||
|     Repo.all( | ||||
|       from c in Container, | ||||
|         left_join: t in assoc(c, :tags), | ||||
|         left_join: ag in assoc(c, :ammo_groups), | ||||
|         where: c.user_id == ^user_id, | ||||
|         where: c.id == ^id, | ||||
|         where: c.id in ^ids, | ||||
|         order_by: c.name, | ||||
|         preload: [tags: t, ammo_groups: ag] | ||||
|         preload: ^@container_preloads, | ||||
|         select: {c.id, c} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -130,7 +148,19 @@ defmodule Cannery.Containers do | ||||
|   @spec create_container(attrs :: map(), User.t()) :: | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def create_container(attrs, %User{} = user) do | ||||
|     %Container{} |> Container.create_changeset(user, attrs) |> Repo.insert() | ||||
|     %Container{} | ||||
|     |> Container.create_changeset(user, attrs) | ||||
|     |> Repo.insert() | ||||
|     |> case do | ||||
|       {:ok, container} -> {:ok, container |> preload_container()} | ||||
|       {:error, changeset} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec preload_container(Container.t()) :: Container.t() | ||||
|   @spec preload_container([Container.t()]) :: [Container.t()] | ||||
|   def preload_container(container) do | ||||
|     container |> Repo.preload(@container_preloads) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -148,7 +178,13 @@ defmodule Cannery.Containers do | ||||
|   @spec update_container(Container.t(), User.t(), attrs :: map()) :: | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def update_container(%Container{user_id: user_id} = container, %User{id: user_id}, attrs) do | ||||
|     container |> Container.update_changeset(attrs) |> Repo.update() | ||||
|     container | ||||
|     |> Container.update_changeset(attrs) | ||||
|     |> Repo.update() | ||||
|     |> case do | ||||
|       {:ok, container} -> {:ok, container |> preload_container()} | ||||
|       {:error, changeset} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -173,7 +209,12 @@ defmodule Cannery.Containers do | ||||
|     ) | ||||
|     |> case do | ||||
|       0 -> | ||||
|         container |> Repo.delete() | ||||
|         container | ||||
|         |> Repo.delete() | ||||
|         |> case do | ||||
|           {:ok, container} -> {:ok, container |> preload_container()} | ||||
|           {:error, changeset} -> {:error, changeset} | ||||
|         end | ||||
|  | ||||
|       _amount -> | ||||
|         error = dgettext("errors", "Container must be empty before deleting") | ||||
| @@ -214,8 +255,11 @@ defmodule Cannery.Containers do | ||||
|         %Container{user_id: user_id} = container, | ||||
|         %Tag{user_id: user_id} = tag, | ||||
|         %User{id: user_id} | ||||
|       ), | ||||
|       do: %ContainerTag{} |> ContainerTag.create_changeset(tag, container) |> Repo.insert!() | ||||
|       ) do | ||||
|     %ContainerTag{} | ||||
|     |> ContainerTag.create_changeset(tag, container) | ||||
|     |> Repo.insert!() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Removes a tag from a container | ||||
| @@ -226,45 +270,175 @@ defmodule Cannery.Containers do | ||||
|       %Container{} | ||||
|  | ||||
|   """ | ||||
|   @spec remove_tag!(Container.t(), Tag.t(), User.t()) :: non_neg_integer() | ||||
|   @spec remove_tag!(Container.t(), Tag.t(), User.t()) :: {non_neg_integer(), [ContainerTag.t()]} | ||||
|   def remove_tag!( | ||||
|         %Container{id: container_id, user_id: user_id}, | ||||
|         %Tag{id: tag_id, user_id: user_id}, | ||||
|         %User{id: user_id} | ||||
|       ) do | ||||
|     {count, _} = | ||||
|     {count, results} = | ||||
|       Repo.delete_all( | ||||
|         from ct in ContainerTag, | ||||
|           where: ct.container_id == ^container_id, | ||||
|           where: ct.tag_id == ^tag_id | ||||
|           where: ct.tag_id == ^tag_id, | ||||
|           select: ct | ||||
|       ) | ||||
|  | ||||
|     if count == 0, do: raise("could not delete container tag"), else: count | ||||
|     if count == 0, do: raise("could not delete container tag"), else: {count, results} | ||||
|   end | ||||
|  | ||||
|   # Container Tags | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of tags. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_tags(%User{id: 123}) | ||||
|       [%Tag{}, ...] | ||||
|  | ||||
|       iex> list_tags("cool", %User{id: 123}) | ||||
|       [%Tag{name: "my cool tag"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_tags(User.t()) :: [Tag.t()] | ||||
|   @spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()] | ||||
|   def list_tags(search \\ nil, user) | ||||
|  | ||||
|   def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) | ||||
|  | ||||
|   def list_tags(search, %{id: user_id}) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from t in Tag, | ||||
|         where: t.user_id == ^user_id, | ||||
|         where: | ||||
|           fragment( | ||||
|             "? @@ websearch_to_tsquery('english', ?)", | ||||
|             t.search, | ||||
|             ^trimmed_search | ||||
|           ), | ||||
|         order_by: { | ||||
|           :desc, | ||||
|           fragment( | ||||
|             "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|             t.search, | ||||
|             ^trimmed_search | ||||
|           ) | ||||
|         } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns number of rounds in container. If data is already preloaded, then | ||||
|   there will be no db hit. | ||||
|   Gets a single tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_tag(123, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> get_tag(456, %User{id: 123}) | ||||
|       {:error, :not_found} | ||||
|  | ||||
|   """ | ||||
|   @spec get_container_ammo_group_count!(Container.t()) :: non_neg_integer() | ||||
|   def get_container_ammo_group_count!(%Container{} = container) do | ||||
|     container | ||||
|     |> Repo.preload(:ammo_groups) | ||||
|     |> Map.fetch!(:ammo_groups) | ||||
|     |> Enum.reject(fn %{count: count} -> count == 0 end) | ||||
|     |> Enum.count() | ||||
|   @spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, :not_found} | ||||
|   def get_tag(id, %User{id: user_id}) do | ||||
|     Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id) | ||||
|     |> case do | ||||
|       nil -> {:error, :not_found} | ||||
|       tag -> {:ok, tag} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns number of rounds in container. If data is already preloaded, then | ||||
|   there will be no db hit. | ||||
|   Gets a single tag. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Tag does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_tag!(123, %User{id: 123}) | ||||
|       %Tag{} | ||||
|  | ||||
|       iex> get_tag!(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_container_rounds!(Container.t()) :: non_neg_integer() | ||||
|   def get_container_rounds!(%Container{} = container) do | ||||
|     container | ||||
|     |> Repo.preload(:ammo_groups) | ||||
|     |> Map.fetch!(:ammo_groups) | ||||
|     |> Enum.map(fn %{count: count} -> count end) | ||||
|     |> Enum.sum() | ||||
|   @spec get_tag!(Tag.id(), User.t()) :: Tag.t() | ||||
|   def get_tag!(id, %User{id: user_id}) do | ||||
|     Repo.one!( | ||||
|       from t in Tag, | ||||
|         where: t.id == ^id, | ||||
|         where: t.user_id == ^user_id | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_tag(%{field: value}, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> create_tag(%{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_tag(attrs :: map(), User.t()) :: | ||||
|           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def create_tag(attrs, %User{} = user) do | ||||
|     %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_tag(tag, %{field: new_value}, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> update_tag(tag, %{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_tag(Tag.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}) do | ||||
|     tag |> Tag.update_changeset(attrs) |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_tag(tag, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> delete_tag(tag, %User{id: 123}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}) do | ||||
|     tag |> Repo.delete() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_tag!(tag, %User{id: 123}) | ||||
|       %Tag{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_tag!(Tag.t(), User.t()) :: Tag.t() | ||||
|   def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}) do | ||||
|     tag |> Repo.delete!() | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -6,8 +6,7 @@ defmodule Cannery.Containers.Container do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.Containers.{Container, ContainerTag} | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Tags.Tag} | ||||
|   alias Cannery.{Accounts.User, Containers.ContainerTag, Containers.Tag} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
| @@ -26,28 +25,25 @@ defmodule Cannery.Containers.Container do | ||||
|     field :location, :string | ||||
|     field :type, :string | ||||
|  | ||||
|     belongs_to :user, User | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     has_many :ammo_groups, AmmoGroup | ||||
|     many_to_many :tags, Tag, join_through: ContainerTag | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %Container{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           desc: String.t(), | ||||
|           location: String.t(), | ||||
|           type: String.t(), | ||||
|           user: User.t(), | ||||
|           user_id: User.id(), | ||||
|           ammo_groups: [AmmoGroup.t()] | nil, | ||||
|           tags: [Tag.t()] | nil, | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_container :: %Container{} | ||||
|   @type new_container :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_container()) | ||||
|  | ||||
| @@ -57,6 +53,8 @@ defmodule Cannery.Containers.Container do | ||||
|     container | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, [:name, :desc, :type, :location]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:type, max: 255) | ||||
|     |> validate_required([:name, :type, :user_id]) | ||||
|   end | ||||
|  | ||||
| @@ -65,6 +63,8 @@ defmodule Cannery.Containers.Container do | ||||
|   def update_changeset(container, attrs) do | ||||
|     container | ||||
|     |> cast(attrs, [:name, :desc, :type, :location]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:type, max: 255) | ||||
|     |> validate_required([:name, :type]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| defmodule Cannery.Containers.ContainerTag do | ||||
|   @moduledoc """ | ||||
|   Thru-table struct for associating Cannery.Containers.Container and | ||||
|   Cannery.Tags.Tag. | ||||
|   Cannery.Containers.Tag. | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.{Containers.Container, Containers.ContainerTag, Tags.Tag} | ||||
|   alias Cannery.Containers.{Container, Tag} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
| @@ -18,7 +18,7 @@ defmodule Cannery.Containers.ContainerTag do | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %ContainerTag{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           container: Container.t(), | ||||
|           container_id: Container.id(), | ||||
| @@ -27,7 +27,7 @@ defmodule Cannery.Containers.ContainerTag do | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_container_tag :: %ContainerTag{} | ||||
|   @type new_container_tag :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_container_tag()) | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| defmodule Cannery.Tags.Tag do | ||||
| defmodule Cannery.Containers.Tag do | ||||
|   @moduledoc """ | ||||
|   Tags are added to containers to help organize, and can include custom-defined | ||||
|   text and bg colors. | ||||
| @@ -6,8 +6,8 @@ defmodule Cannery.Tags.Tag do | ||||
| 
 | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.Accounts.User | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Cannery.{Accounts.User, Tags.Tag} | ||||
| 
 | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
| @@ -23,22 +23,21 @@ defmodule Cannery.Tags.Tag do | ||||
|     field :bg_color, :string | ||||
|     field :text_color, :string | ||||
| 
 | ||||
|     belongs_to :user, User | ||||
|     field :user_id, :binary_id | ||||
| 
 | ||||
|     timestamps() | ||||
|   end | ||||
| 
 | ||||
|   @type t :: %Tag{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           bg_color: String.t(), | ||||
|           text_color: String.t(), | ||||
|           user: User.t() | nil, | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_tag() :: %Tag{} | ||||
|   @type new_tag() :: %__MODULE__{} | ||||
|   @type id() :: UUID.t() | ||||
|   @type changeset() :: Changeset.t(t() | new_tag()) | ||||
| 
 | ||||
| @@ -48,6 +47,9 @@ defmodule Cannery.Tags.Tag do | ||||
|     tag | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:bg_color, max: 12) | ||||
|     |> validate_length(:text_color, max: 12) | ||||
|     |> validate_required([:name, :bg_color, :text_color, :user_id]) | ||||
|   end | ||||
| 
 | ||||
| @@ -56,6 +58,9 @@ defmodule Cannery.Tags.Tag do | ||||
|   def update_changeset(tag, attrs) do | ||||
|     tag | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:bg_color, max: 12) | ||||
|     |> validate_length(:text_color, max: 12) | ||||
|     |> validate_required([:name, :bg_color, :text_color]) | ||||
|   end | ||||
| end | ||||
| @@ -1,4 +1,4 @@ | ||||
| defmodule Cannery.ErrorReporter do | ||||
| defmodule Cannery.Logger do | ||||
|   @moduledoc """ | ||||
|   Custom logger for telemetry events | ||||
| 
 | ||||
| @@ -12,41 +12,47 @@ defmodule Cannery.ErrorReporter do | ||||
|     data = | ||||
|       get_oban_job_data(meta, measure) | ||||
|       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) | ||||
|       |> pretty_encode() | ||||
| 
 | ||||
|     Logger.error(meta.reason, data: pretty_encode(data)) | ||||
|     Logger.error(meta.reason, data: data) | ||||
|   end | ||||
| 
 | ||||
|   def handle_event([:oban, :job, :start], measure, meta, _config) do | ||||
|     Logger.info("Started oban job", data: get_oban_job_data(meta, measure) |> pretty_encode()) | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Started oban job", data: data) | ||||
|   end | ||||
| 
 | ||||
|   def handle_event([:oban, :job, :stop], measure, meta, _config) do | ||||
|     Logger.info("Finished oban job", data: get_oban_job_data(meta, measure) |> pretty_encode()) | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Finished oban job", data: data) | ||||
|   end | ||||
| 
 | ||||
|   def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do | ||||
|     data = | ||||
|       get_oban_job_data(meta, measure) | ||||
|       |> Map.put(:event, unhandled_event) | ||||
|       |> pretty_encode() | ||||
| 
 | ||||
|     Logger.warning("Unhandled oban job event", data: pretty_encode(data)) | ||||
|     Logger.warning("Unhandled oban job event", data: data) | ||||
|   end | ||||
| 
 | ||||
|   def handle_event(unhandled_event, measure, meta, config) do | ||||
|     data = %{ | ||||
|     data = | ||||
|       pretty_encode(%{ | ||||
|         event: unhandled_event, | ||||
|         meta: meta, | ||||
|         measurements: measure, | ||||
|         config: config | ||||
|     } | ||||
|       }) | ||||
| 
 | ||||
|     Logger.warning("Unhandled telemetry event", data: pretty_encode(data)) | ||||
|     Logger.warning("Unhandled telemetry event", data: data) | ||||
|   end | ||||
| 
 | ||||
|   defp get_oban_job_data(%{job: job}, measure) do | ||||
|     job | ||||
|     |> Map.take([:id, :args, :meta, :queue, :worker]) | ||||
|     |> Map.merge(measure) | ||||
|     %{ | ||||
|       job: job |> Map.take([:id, :args, :meta, :queue, :worker]), | ||||
|       measurements: measure | ||||
|     } | ||||
|   end | ||||
| 
 | ||||
|   defp pretty_encode(data) do | ||||
| @@ -9,7 +9,9 @@ defmodule Cannery.Release do | ||||
|  | ||||
|   def rollback(repo, version) do | ||||
|     load_app() | ||||
|     {:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) | ||||
|  | ||||
|     {:ok, _fun_return, _apps} = | ||||
|       Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) | ||||
|   end | ||||
|  | ||||
|   defp load_app do | ||||
| @@ -20,7 +22,8 @@ defmodule Cannery.Release do | ||||
|     load_app() | ||||
|  | ||||
|     for repo <- Application.fetch_env!(@app, :ecto_repos) do | ||||
|       {:ok, _fun, _opts} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) | ||||
|       {:ok, _fun_return, _apps} = | ||||
|         Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| defmodule Cannery.Repo.Migrator do | ||||
|   @moduledoc """ | ||||
|   Genserver to automatically run migrations in prod env | ||||
|   Genserver to automatically perform all migration on app start | ||||
|   """ | ||||
|  | ||||
|   use GenServer | ||||
|   | ||||
| @@ -1,149 +0,0 @@ | ||||
| defmodule Cannery.Tags do | ||||
|   @moduledoc """ | ||||
|   The Tags context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts.User, Repo, Tags.Tag} | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of tags. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_tags(%User{id: 123}) | ||||
|       [%Tag{}, ...] | ||||
|  | ||||
|       iex> list_tags("cool", %User{id: 123}) | ||||
|       [%Tag{name: "my cool tag"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_tags(User.t()) :: [Tag.t()] | ||||
|   @spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()] | ||||
|   def list_tags(search \\ nil, user) | ||||
|  | ||||
|   def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name) | ||||
|  | ||||
|   def list_tags(search, %{id: user_id}) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from t in Tag, | ||||
|         where: t.user_id == ^user_id, | ||||
|         where: | ||||
|           fragment( | ||||
|             "search @@ websearch_to_tsquery('english', ?)", | ||||
|             ^trimmed_search | ||||
|           ), | ||||
|         order_by: { | ||||
|           :desc, | ||||
|           fragment( | ||||
|             "ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)", | ||||
|             ^trimmed_search | ||||
|           ) | ||||
|         } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_tag(123, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> get_tag(456, %User{id: 123}) | ||||
|       {:error, "tag not found"} | ||||
|  | ||||
|   """ | ||||
|   @spec get_tag(Tag.id(), User.t()) :: {:ok, Tag.t()} | {:error, String.t()} | ||||
|   def get_tag(id, %User{id: user_id}) do | ||||
|     Repo.one(from t in Tag, where: t.id == ^id and t.user_id == ^user_id) | ||||
|     |> case do | ||||
|       nil -> {:error, dgettext("errors", "Tag not found")} | ||||
|       tag -> {:ok, tag} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single tag. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Tag does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_tag!(123, %User{id: 123}) | ||||
|       %Tag{} | ||||
|  | ||||
|       iex> get_tag!(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_tag!(Tag.id(), User.t()) :: Tag.t() | ||||
|   def get_tag!(id, %User{id: user_id}), | ||||
|     do: Repo.one!(from t in Tag, where: t.id == ^id and t.user_id == ^user_id) | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_tag(%{field: value}, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> create_tag(%{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_tag(attrs :: map(), User.t()) :: | ||||
|           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def create_tag(attrs, %User{} = user), | ||||
|     do: %Tag{} |> Tag.create_changeset(user, attrs) |> Repo.insert() | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_tag(tag, %{field: new_value}, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> update_tag(tag, %{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_tag(Tag.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def update_tag(%Tag{user_id: user_id} = tag, attrs, %User{id: user_id}), | ||||
|     do: tag |> Tag.update_changeset(attrs) |> Repo.update() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_tag(tag, %User{id: 123}) | ||||
|       {:ok, %Tag{}} | ||||
|  | ||||
|       iex> delete_tag(tag, %User{id: 123}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_tag(Tag.t(), User.t()) :: {:ok, Tag.t()} | {:error, Tag.changeset()} | ||||
|   def delete_tag(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a tag. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_tag!(tag, %User{id: 123}) | ||||
|       %Tag{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_tag!(Tag.t(), User.t()) :: Tag.t() | ||||
|   def delete_tag!(%Tag{user_id: user_id} = tag, %User{id: user_id}), do: tag |> Repo.delete!() | ||||
| end | ||||
| @@ -44,8 +44,7 @@ defmodule CanneryWeb do | ||||
|  | ||||
|   def live_view do | ||||
|     quote do | ||||
|       use Phoenix.LiveView, | ||||
|         layout: {CanneryWeb.LayoutView, "live.html"} | ||||
|       use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live} | ||||
|  | ||||
|       on_mount CanneryWeb.InitAssigns | ||||
|       unquote(view_helpers()) | ||||
| @@ -94,7 +93,7 @@ defmodule CanneryWeb do | ||||
|  | ||||
|       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) | ||||
|       # Import basic rendering functionality (render, render_layout, etc) | ||||
|       import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers} | ||||
|       import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers} | ||||
|       import Phoenix.{Component, View} | ||||
|  | ||||
|       alias CanneryWeb.Endpoint | ||||
|   | ||||
| @@ -5,6 +5,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup} | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.{JS, Socket} | ||||
|  | ||||
|   @impl true | ||||
| @@ -18,7 +19,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do | ||||
|     changeset = | ||||
|       %ShotGroup{date: NaiveDateTime.utc_now(), count: 1} | ||||
|       %ShotGroup{date: Date.utc_today()} | ||||
|       |> ShotGroup.create_changeset(current_user, ammo_group, %{}) | ||||
|  | ||||
|     {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} | ||||
| @@ -32,10 +33,13 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|       ) do | ||||
|     params = shot_group_params |> process_params(ammo_group) | ||||
|  | ||||
|     changeset = %ShotGroup{} |> ShotGroup.create_changeset(current_user, ammo_group, params) | ||||
|  | ||||
|     changeset = | ||||
|       %ShotGroup{} | ||||
|       |> ShotGroup.create_changeset(current_user, ammo_group, params) | ||||
|       |> Map.put(:action, :validate) | ||||
|       case changeset |> Changeset.apply_action(:validate) do | ||||
|         {:ok, _data} -> changeset | ||||
|         {:error, changeset} -> changeset | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket |> assign(:changeset, changeset)} | ||||
|   end | ||||
| @@ -56,7 +60,7 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|           prompt = dgettext("prompts", "Shots recorded successfully") | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|       end | ||||
|  | ||||
| @@ -65,14 +69,14 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|  | ||||
|   # calculate count from shots left | ||||
|   defp process_params(params, %AmmoGroup{count: count}) do | ||||
|     new_count = | ||||
|       if params |> Map.get("ammo_left", "0") == "" do | ||||
|         "0" | ||||
|     shot_group_count = | ||||
|       if params |> Map.get("ammo_left", "") == "" do | ||||
|         nil | ||||
|       else | ||||
|         params |> Map.get("ammo_left", "0") | ||||
|         new_count = params |> Map.get("ammo_left") |> String.to_integer() | ||||
|         count - new_count | ||||
|       end | ||||
|       |> String.to_integer() | ||||
|  | ||||
|     params |> Map.put("count", count - new_count) | ||||
|     params |> Map.put("count", shot_group_count) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -37,9 +37,12 @@ | ||||
|  | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       id: "add-shot-group-form-notes", | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: "Really great weather", | ||||
|       phx_hook: "MaintainAttrs" | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Really great weather"), | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|   | ||||
| @@ -1,103 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.AmmoGroupCard do | ||||
|   @moduledoc """ | ||||
|   Display card for an ammo group | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Repo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   attr :ammo_group, AmmoGroup, required: true | ||||
|   attr :show_container, :boolean, default: false | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   def ammo_group_card(%{ammo_group: ammo_group} = assigns) do | ||||
|     assigns = | ||||
|       %{show_container: show_container} = assigns |> assign_new(:show_container, fn -> false end) | ||||
|  | ||||
|     preloads = if show_container, do: [:ammo_type, :container], else: [:ammo_type] | ||||
|     ammo_group = ammo_group |> Repo.preload(preloads) | ||||
|  | ||||
|     assigns = assigns |> assign(:ammo_group, ammo_group) | ||||
|  | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"ammo_group-#{@ammo_group.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 | ||||
|         flex flex-col justify-center items-center | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link"> | ||||
|         <h1 class="title text-xl title-primary-500"> | ||||
|           <%= @ammo_group.ammo_type.name %> | ||||
|         </h1> | ||||
|       </.link> | ||||
|  | ||||
|       <div class="flex flex-col justify-center items-center"> | ||||
|         <span class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Count:") %> | ||||
|           <%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %> | ||||
|         </span> | ||||
|  | ||||
|         <span | ||||
|           :if={@ammo_group |> Ammo.get_original_count() != @ammo_group.count} | ||||
|           class="rounded-lg title text-lg" | ||||
|         > | ||||
|           <%= gettext("Original Count:") %> | ||||
|           <%= @ammo_group |> Ammo.get_original_count() %> | ||||
|         </span> | ||||
|  | ||||
|         <span :if={@ammo_group.notes} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Notes:") %> | ||||
|           <%= @ammo_group.notes %> | ||||
|         </span> | ||||
|  | ||||
|         <span class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Purchased on:") %> | ||||
|           <.date date={@ammo_group.purchased_on} /> | ||||
|         </span> | ||||
|  | ||||
|         <span :if={@ammo_group |> Ammo.get_last_used_shot_group()} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Last used on:") %> | ||||
|           <.date date={@ammo_group |> Ammo.get_last_used_shot_group() |> Map.get(:date)} /> | ||||
|         </span> | ||||
|  | ||||
|         <%= if @ammo_group.price_paid do %> | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("Price paid:") %> | ||||
|             <%= gettext("$%{amount}", | ||||
|               amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2) | ||||
|             ) %> | ||||
|           </span> | ||||
|  | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("CPR:") %> | ||||
|             <%= gettext("$%{amount}", | ||||
|               amount: @ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2) | ||||
|             ) %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|  | ||||
|         <span :if={@show_container and @ammo_group.container} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Container:") %> | ||||
|  | ||||
|           <.link | ||||
|             navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container)} | ||||
|             class="link" | ||||
|           > | ||||
|             <%= @ammo_group.container.name %> | ||||
|           </.link> | ||||
|         </span> | ||||
|       </div> | ||||
|  | ||||
|       <div | ||||
|         :if={assigns |> Map.has_key?(:inner_block)} | ||||
|         class="mt-4 flex space-x-4 justify-center items-center" | ||||
|       > | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -3,7 +3,9 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|   A component that displays a list of ammo groups | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Repo} | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, ComparableDate} | ||||
|   alias Cannery.{ActivityLog, Ammo, Containers} | ||||
|   alias CanneryWeb.Components.TableComponent | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
| @@ -13,6 +15,7 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             required(:ammo_groups) => [AmmoGroup.t()], | ||||
|             required(:show_used) => boolean(), | ||||
|             optional(:ammo_type) => Rendered.t(), | ||||
|             optional(:range) => Rendered.t(), | ||||
|             optional(:container) => Rendered.t(), | ||||
| @@ -21,7 +24,11 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, ammo_groups: _ammo_group, current_user: _current_user} = assigns, socket) do | ||||
|   def update( | ||||
|         %{id: _id, ammo_groups: _ammo_group, current_user: _current_user, show_used: _show_used} = | ||||
|           assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
| @@ -42,65 +49,75 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|              ammo_type: ammo_type, | ||||
|              range: range, | ||||
|              container: container, | ||||
|              actions: actions | ||||
|              actions: actions, | ||||
|              show_used: show_used | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|       if actions == [] do | ||||
|       [] | ||||
|       else | ||||
|         [%{label: nil, key: :actions, sortable: false}] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Purchased on"), key: :purchased_on}, | ||||
|       %{label: gettext("Last used on"), key: :used_up_on} | columns | ||||
|     ] | ||||
|  | ||||
|     columns = | ||||
|       if container == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Container"), key: :container} | columns] | ||||
|       end | ||||
|  | ||||
|     columns = | ||||
|       if range == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Range"), key: :range} | columns] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Count"), key: :count}, | ||||
|       %{label: gettext("Original Count"), key: :original_count}, | ||||
|       %{label: gettext("Price paid"), key: :price_paid}, | ||||
|       %{label: gettext("CPR"), key: :cpr}, | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Actions"), key: :actions, sortable: false}, | ||||
|         actions != [] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Last used on"), | ||||
|         key: :used_up_on, | ||||
|         type: ComparableDate | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Purchased on"), | ||||
|         key: :purchased_on, | ||||
|         type: ComparableDate | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Container"), key: :container}, | ||||
|         container != [] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Range"), key: :range}, | ||||
|         range != [] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{label: gettext("CPR"), key: :cpr}) | ||||
|       |> TableComponent.maybe_compose_columns(%{label: gettext("Price paid"), key: :price_paid}) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("% left"), key: :remaining}, | ||||
|       %{label: gettext("Notes"), key: :notes} | ||||
|       | columns | ||||
|     ] | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Original Count"), key: :original_count}, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: if(show_used, do: gettext("Current Count"), else: gettext("Count")), | ||||
|         key: :count | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Ammo type"), key: :ammo_type}, | ||||
|         ammo_type != [] | ||||
|       ) | ||||
|  | ||||
|     columns = | ||||
|       if ammo_type == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Ammo type"), key: :ammo_type} | columns] | ||||
|       end | ||||
|     containers = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %{container_id: container_id} -> container_id end) | ||||
|       |> Containers.get_containers(current_user) | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       ammo_type: ammo_type, | ||||
|       columns: columns, | ||||
|       container: container, | ||||
|       containers: containers, | ||||
|       original_counts: Ammo.get_original_counts(ammo_groups, current_user), | ||||
|       cprs: Ammo.get_cprs(ammo_groups, current_user), | ||||
|       last_used_dates: ActivityLog.get_last_used_dates(ammo_groups, current_user), | ||||
|       percentages_remaining: Ammo.get_percentages_remaining(ammo_groups, current_user), | ||||
|       actions: actions, | ||||
|       range: range | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       ammo_groups | ||||
|       |> Repo.preload([:ammo_type, :container]) | ||||
|       |> Enum.map(fn ammo_group -> | ||||
|         ammo_group |> get_row_data_for_ammo_group(extra_data) | ||||
|       end) | ||||
| @@ -112,20 +129,13 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map() | ||||
|   defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do | ||||
|     ammo_group = ammo_group |> Repo.preload([:ammo_type, :container]) | ||||
|  | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_value_for_key(key, ammo_group, additional_data)} | ||||
| @@ -147,33 +157,27 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), do: {"", nil} | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), | ||||
|     do: {0, gettext("No cost information")} | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data), | ||||
|     do: gettext("$%{amount}", amount: price_paid |> :erlang.float_to_binary(decimals: 2)) | ||||
|  | ||||
|   defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on}, _additional_data) do | ||||
|     assigns = %{purchased_on: purchased_on} | ||||
|     do: {price_paid, gettext("$%{amount}", amount: display_currency(price_paid))} | ||||
|  | ||||
|   defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do | ||||
|     {purchased_on, | ||||
|      ~H""" | ||||
|      <.date date={@purchased_on} /> | ||||
|      <.date id={"#{@id}-purchased-on"} date={@purchased_on} /> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:used_up_on, ammo_group, _additional_data) do | ||||
|     last_shot_group_date = | ||||
|       case ammo_group |> Ammo.get_last_used_shot_group() do | ||||
|         %{date: last_shot_group_date} -> last_shot_group_date | ||||
|         _no_shot_groups -> nil | ||||
|       end | ||||
|   defp get_value_for_key(:used_up_on, %{id: ammo_group_id}, %{last_used_dates: last_used_dates}) do | ||||
|     last_used_date = last_used_dates |> Map.get(ammo_group_id) | ||||
|     assigns = %{id: ammo_group_id, last_used_date: last_used_date} | ||||
|  | ||||
|     assigns = %{last_shot_group_date: last_shot_group_date} | ||||
|  | ||||
|     {last_shot_group_date, | ||||
|     {last_used_date, | ||||
|      ~H""" | ||||
|      <%= if @last_shot_group_date do %> | ||||
|        <.date date={@last_shot_group_date} /> | ||||
|      <%= if @last_used_date do %> | ||||
|        <.date id={"#{@id}-last-used-date"} date={@last_used_date} /> | ||||
|      <% else %> | ||||
|        <%= gettext("Never used") %> | ||||
|      <% end %> | ||||
| @@ -189,8 +193,14 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:remaining, ammo_group, _additional_data), | ||||
|     do: gettext("%{percentage}%", percentage: ammo_group |> Ammo.get_percentage_remaining()) | ||||
|   defp get_value_for_key( | ||||
|          :remaining, | ||||
|          %{id: ammo_group_id}, | ||||
|          %{percentages_remaining: percentages_remaining} | ||||
|        ) do | ||||
|     percentage = Map.fetch!(percentages_remaining, ammo_group_id) | ||||
|     {percentage, gettext("%{percentage}%", percentage: percentage)} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, ammo_group: ammo_group} | ||||
| @@ -204,31 +214,44 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|  | ||||
|   defp get_value_for_key( | ||||
|          :container, | ||||
|          %{container: %{name: container_name}} = ammo_group, | ||||
|          %{container: container} | ||||
|          %{container_id: container_id} = ammo_group, | ||||
|          %{container: container_block, containers: containers} | ||||
|        ) do | ||||
|     assigns = %{container: container, ammo_group: ammo_group} | ||||
|     container = %{name: container_name} = Map.fetch!(containers, container_id) | ||||
|  | ||||
|     assigns = %{ | ||||
|       container: container, | ||||
|       container_block: container_block, | ||||
|       ammo_group: ammo_group | ||||
|     } | ||||
|  | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@container, @ammo_group) %> | ||||
|      <%= render_slot(@container_block, {@ammo_group, @container}) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:original_count, ammo_group, _additional_data), | ||||
|     do: ammo_group |> Ammo.get_original_count() | ||||
|   defp get_value_for_key( | ||||
|          :original_count, | ||||
|          %{id: ammo_group_id}, | ||||
|          %{original_counts: original_counts} | ||||
|        ) do | ||||
|     Map.fetch!(original_counts, ammo_group_id) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), | ||||
|     do: gettext("No cost information") | ||||
|     do: {0, gettext("No cost information")} | ||||
|  | ||||
|   defp get_value_for_key(:cpr, ammo_group, _additional_data) do | ||||
|     gettext("$%{amount}", | ||||
|       amount: ammo_group |> Ammo.get_cpr() |> :erlang.float_to_binary(decimals: 2) | ||||
|     ) | ||||
|   defp get_value_for_key(:cpr, %{id: ammo_group_id}, %{cprs: cprs}) do | ||||
|     amount = Map.fetch!(cprs, ammo_group_id) | ||||
|     {amount, gettext("$%{amount}", amount: display_currency(amount))} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:count, %{count: count}, _additional_data), | ||||
|     do: if(count == 0, do: gettext("Empty"), else: count) | ||||
|     do: if(count == 0, do: {0, gettext("Empty")}, else: count) | ||||
|  | ||||
|   defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
|   | ||||
| @@ -3,7 +3,8 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|   A component that displays a list of ammo type | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoType} | ||||
|   alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.AmmoType} | ||||
|   alias CanneryWeb.Components.TableComponent | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
| @@ -12,6 +13,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:class) => AmmoType.class() | nil, | ||||
|             optional(:show_used) => boolean(), | ||||
|             optional(:ammo_types) => [AmmoType.t()], | ||||
|             optional(:actions) => Rendered.t(), | ||||
| @@ -24,6 +26,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:show_used, fn -> false end) | ||||
|       |> assign_new(:class, fn -> :all end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_ammo_types() | ||||
|  | ||||
| @@ -36,92 +39,146 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|              ammo_types: ammo_types, | ||||
|              current_user: current_user, | ||||
|              show_used: show_used, | ||||
|              class: class, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|     filtered_columns = | ||||
|       [ | ||||
|         %{label: gettext("Name"), key: :name, type: :name}, | ||||
|         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, | ||||
|         %{label: gettext("Bullet core"), key: :bullet_core, type: :string}, | ||||
|         %{label: gettext("Cartridge"), key: :cartridge, type: :string}, | ||||
|         %{label: gettext("Caliber"), key: :caliber, type: :string}, | ||||
|         %{label: gettext("Case material"), key: :case_material, type: :string}, | ||||
|         %{ | ||||
|           label: if(class == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")), | ||||
|           key: :caliber, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Unfired shell length"), key: :unfired_length, type: :string}, | ||||
|         %{label: gettext("Brass height"), key: :brass_height, type: :string}, | ||||
|         %{label: gettext("Chamber size"), key: :chamber_size, type: :string}, | ||||
|         %{label: gettext("Chamber size"), key: :chamber_size, type: :string}, | ||||
|         %{label: gettext("Grains"), key: :grains, type: :string}, | ||||
|         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, | ||||
|         %{ | ||||
|           label: if(class == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")), | ||||
|           key: :bullet_core, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Jacket type"), key: :jacket_type, type: :string}, | ||||
|         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, | ||||
|         %{label: gettext("Case material"), key: :case_material, type: :string}, | ||||
|         %{label: gettext("Wadding"), key: :wadding, type: :string}, | ||||
|         %{label: gettext("Shot type"), key: :shot_type, type: :string}, | ||||
|         %{label: gettext("Shot material"), key: :shot_material, type: :string}, | ||||
|         %{label: gettext("Shot size"), key: :shot_size, type: :string}, | ||||
|         %{label: gettext("Load grains"), key: :load_grains, type: :string}, | ||||
|         %{label: gettext("Shot charge weight"), key: :shot_charge_weight, type: :string}, | ||||
|         %{label: gettext("Powder type"), key: :powder_type, type: :string}, | ||||
|         %{ | ||||
|           label: gettext("Powder grains per charge"), | ||||
|           key: :powder_grains_per_charge, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Grains"), key: :grains, type: :string}, | ||||
|         %{label: gettext("Pressure"), key: :pressure, type: :string}, | ||||
|         %{label: gettext("Dram equivalent"), key: :dram_equivalent, type: :string}, | ||||
|         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, | ||||
|         %{label: gettext("Primer type"), key: :primer_type, type: :string}, | ||||
|         %{label: gettext("Firing type"), key: :firing_type, type: :string}, | ||||
|         %{label: gettext("Tracer"), key: :tracer, type: :boolean}, | ||||
|         %{label: gettext("Incendiary"), key: :incendiary, type: :boolean}, | ||||
|         %{label: gettext("Blank"), key: :blank, type: :boolean}, | ||||
|         %{label: gettext("Corrosive"), key: :corrosive, type: :boolean}, | ||||
|         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string}, | ||||
|         %{label: gettext("UPC"), key: "upc", type: :string} | ||||
|         %{label: gettext("Tracer"), key: :tracer, type: :atom}, | ||||
|         %{label: gettext("Incendiary"), key: :incendiary, type: :atom}, | ||||
|         %{label: gettext("Blank"), key: :blank, type: :atom}, | ||||
|         %{label: gettext("Corrosive"), key: :corrosive, type: :atom}, | ||||
|         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
|         default_value = if type == :boolean, do: false, else: nil | ||||
|         default_value = if type == :atom, do: false, else: nil | ||||
|  | ||||
|         ammo_types | ||||
|         |> Enum.any?(fn ammo_type -> | ||||
|           not (ammo_type |> Map.get(key) == default_value) | ||||
|         |> Enum.any?(fn ammo_type -> Map.get(ammo_type, key, default_value) != default_value end) | ||||
|       end) | ||||
|       end) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Rounds"), key: :round_count, type: :round_count} | ||||
|       ]) | ||||
|       |> Kernel.++( | ||||
|         if show_used do | ||||
|           [ | ||||
|  | ||||
|     columns = | ||||
|       [%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}] | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Average CPR"), | ||||
|         key: :avg_price_paid, | ||||
|         type: :avg_price_paid | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Total ever packs"), | ||||
|           key: :historical_pack_count, | ||||
|           type: :historical_pack_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Used packs"), | ||||
|           key: :used_pack_count, | ||||
|           type: :used_pack_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Packs"), | ||||
|         key: :ammo_count, | ||||
|         type: :ammo_count | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Total ever rounds"), | ||||
|           key: :historical_round_count, | ||||
|           type: :historical_round_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Used rounds"), | ||||
|           key: :used_round_count, | ||||
|           type: :used_round_count | ||||
|         }, | ||||
|             %{ | ||||
|               label: gettext("Total ever rounds"), | ||||
|               key: :historical_round_count, | ||||
|               type: :historical_round_count | ||||
|             } | ||||
|           ] | ||||
|         else | ||||
|           [] | ||||
|         end | ||||
|         show_used | ||||
|       ) | ||||
|       |> Kernel.++([%{label: gettext("Packs"), key: :ammo_count, type: :ammo_count}]) | ||||
|       |> Kernel.++( | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Rounds"), | ||||
|         key: :round_count, | ||||
|         type: :round_count | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns(filtered_columns) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Class"), key: :type, type: :atom}, | ||||
|         class in [:all, nil] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name}) | ||||
|  | ||||
|     round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user) | ||||
|     packs_count = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user) | ||||
|     average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user) | ||||
|  | ||||
|     [used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] = | ||||
|       if show_used do | ||||
|         [ | ||||
|             %{ | ||||
|               label: gettext("Used packs"), | ||||
|               key: :used_ammo_count, | ||||
|               type: :used_ammo_count | ||||
|             }, | ||||
|             %{ | ||||
|               label: gettext("Total ever packs"), | ||||
|               key: :historical_ammo_count, | ||||
|               type: :historical_ammo_count | ||||
|             } | ||||
|           ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user), | ||||
|           ammo_types |> Ammo.get_historical_count_for_ammo_types(current_user), | ||||
|           ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true), | ||||
|           ammo_types |> Ammo.get_used_ammo_groups_count_for_types(current_user) | ||||
|         ] | ||||
|       else | ||||
|           [] | ||||
|         [nil, nil, nil, nil] | ||||
|       end | ||||
|       ) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Average CPR"), key: :avg_price_paid, type: :avg_price_paid}, | ||||
|         %{label: nil, key: "actions", type: :actions, sortable: false} | ||||
|       ]) | ||||
|  | ||||
|     extra_data = %{actions: actions, current_user: current_user} | ||||
|     extra_data = %{ | ||||
|       actions: actions, | ||||
|       current_user: current_user, | ||||
|       used_counts: used_counts, | ||||
|       round_counts: round_counts, | ||||
|       historical_round_counts: historical_round_counts, | ||||
|       packs_count: packs_count, | ||||
|       used_pack_counts: used_pack_counts, | ||||
|       historical_pack_counts: historical_pack_counts, | ||||
|       average_costs: average_costs | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       ammo_types | ||||
| @@ -136,12 +193,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| @@ -153,46 +205,72 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:boolean, key, ammo_type, _other_data), | ||||
|   defp get_ammo_type_value(:atom, key, ammo_type, _other_data), | ||||
|     do: ammo_type |> Map.get(key) |> humanize() | ||||
|  | ||||
|   defp get_ammo_type_value(:round_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) | ||||
|   defp get_ammo_type_value(:round_count, _key, %{id: ammo_type_id}, %{round_counts: round_counts}), | ||||
|     do: Map.get(round_counts, ammo_type_id, 0) | ||||
|  | ||||
|   defp get_ammo_type_value(:historical_round_count, _key, ammo_type, %{current_user: current_user}), | ||||
|        do: ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user) | ||||
|   defp get_ammo_type_value( | ||||
|          :historical_round_count, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{historical_round_counts: historical_round_counts} | ||||
|        ) do | ||||
|     Map.get(historical_round_counts, ammo_type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:used_round_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) | ||||
|   defp get_ammo_type_value( | ||||
|          :used_round_count, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{used_counts: used_counts} | ||||
|        ) do | ||||
|     Map.get(used_counts, ammo_type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:historical_ammo_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) | ||||
|   defp get_ammo_type_value( | ||||
|          :historical_pack_count, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{historical_pack_counts: historical_pack_counts} | ||||
|        ) do | ||||
|     Map.get(historical_pack_counts, ammo_type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:used_ammo_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user) | ||||
|   defp get_ammo_type_value( | ||||
|          :used_pack_count, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{used_pack_counts: used_pack_counts} | ||||
|        ) do | ||||
|     Map.get(used_pack_counts, ammo_type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:ammo_count, _key, ammo_type, %{current_user: current_user}), | ||||
|     do: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user) | ||||
|   defp get_ammo_type_value(:ammo_count, _key, %{id: ammo_type_id}, %{packs_count: packs_count}), | ||||
|     do: Map.get(packs_count, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value(:avg_price_paid, _key, ammo_type, %{current_user: current_user}) do | ||||
|     case ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) do | ||||
|       nil -> gettext("No cost information") | ||||
|       count -> gettext("$%{amount}", amount: count |> :erlang.float_to_binary(decimals: 2)) | ||||
|   defp get_ammo_type_value( | ||||
|          :avg_price_paid, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{average_costs: average_costs} | ||||
|        ) do | ||||
|     case Map.get(average_costs, ammo_type_id) do | ||||
|       nil -> {0, gettext("No cost information")} | ||||
|       count -> {count, gettext("$%{amount}", amount: display_currency(count))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:name, _key, ammo_type, _other_data) do | ||||
|   defp get_ammo_type_value(:name, _key, %{name: ammo_type_name} = ammo_type, _other_data) do | ||||
|     assigns = %{ammo_type: ammo_type} | ||||
|  | ||||
|     {ammo_type_name, | ||||
|      ~H""" | ||||
|     <.link | ||||
|       navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} | ||||
|       class="link" | ||||
|       data-qa={"view-name-#{@ammo_type.id}"} | ||||
|     > | ||||
|      <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} class="link"> | ||||
|        <%= @ammo_type.name %> | ||||
|      </.link> | ||||
|     """ | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do | ||||
| @@ -206,4 +284,7 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|   defp get_ammo_type_value(nil, _key, _ammo_type, _other_data), do: nil | ||||
|  | ||||
|   defp get_ammo_type_value(_other, key, ammo_type, _other_data), do: ammo_type |> Map.get(key) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
|   | ||||
| @@ -1,81 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.ContainerCard do | ||||
|   @moduledoc """ | ||||
|   Display card for a container | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   import CanneryWeb.Components.TagCard | ||||
|   alias Cannery.{Containers, Containers.Container, Repo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Phoenix.LiveView.Rendered | ||||
|  | ||||
|   attr :container, Container, required: true | ||||
|   slot(:tag_actions) | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @spec container_card(assigns :: map()) :: Rendered.t() | ||||
|   def container_card(%{container: container} = assigns) do | ||||
|     assigns = | ||||
|       assigns | ||||
|       |> assign(container: container |> Repo.preload([:tags, :ammo_groups])) | ||||
|       |> assign_new(:tag_actions, fn -> [] end) | ||||
|  | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"container-#{@container.id}"} | ||||
|       class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 | ||||
|         flex flex-col justify-center items-center space-y-4 | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <div class="max-w-full mb-4 flex flex-col justify-center items-center space-y-2"> | ||||
|         <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> | ||||
|           <h1 class="px-4 py-2 rounded-lg title text-xl"> | ||||
|             <%= @container.name %> | ||||
|           </h1> | ||||
|         </.link> | ||||
|  | ||||
|         <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Description:") %> | ||||
|           <%= @container.desc %> | ||||
|         </span> | ||||
|  | ||||
|         <span class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Type:") %> | ||||
|           <%= @container.type %> | ||||
|         </span> | ||||
|  | ||||
|         <span :if={@container.location} class="rounded-lg title text-lg"> | ||||
|           <%= gettext("Location:") %> | ||||
|           <%= @container.location %> | ||||
|         </span> | ||||
|  | ||||
|         <%= unless @container.ammo_groups |> Enum.empty?() do %> | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("Packs:") %> | ||||
|             <%= @container |> Containers.get_container_ammo_group_count!() %> | ||||
|           </span> | ||||
|  | ||||
|           <span class="rounded-lg title text-lg"> | ||||
|             <%= gettext("Rounds:") %> | ||||
|             <%= @container |> Containers.get_container_rounds!() %> | ||||
|           </span> | ||||
|         <% end %> | ||||
|  | ||||
|         <div class="flex flex-wrap justify-center items-center"> | ||||
|           <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||
|  | ||||
|           <%= render_slot(@tag_actions) %> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div | ||||
|         :if={assigns |> Map.has_key?(:inner_block)} | ||||
|         class="flex space-x-4 justify-center items-center" | ||||
|       > | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -3,8 +3,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|   A component that displays a list of containers | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container, Repo} | ||||
|   alias CanneryWeb.Components.TagCard | ||||
|   alias Cannery.{Accounts.User, Ammo, Containers.Container} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
| @@ -46,11 +45,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|         %{label: gettext("Name"), key: :name, type: :string}, | ||||
|         %{label: gettext("Description"), key: :desc, type: :string}, | ||||
|         %{label: gettext("Location"), key: :location, type: :string}, | ||||
|         %{label: gettext("Type"), key: :type, type: :string}, | ||||
|         %{label: gettext("Packs"), key: :packs, type: :integer}, | ||||
|         %{label: gettext("Rounds"), key: :rounds, type: :string}, | ||||
|         %{label: gettext("Tags"), key: :tags, type: :tags}, | ||||
|         %{label: nil, key: :actions, sortable: false, type: :actions} | ||||
|         %{label: gettext("Type"), key: :type, type: :string} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
| @@ -65,11 +60,19 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|           type in [:tags, :actions] or not (container |> Map.get(key) == default_value) | ||||
|         end) | ||||
|       end) | ||||
|       |> Enum.concat([ | ||||
|         %{label: gettext("Packs"), key: :packs, type: :integer}, | ||||
|         %{label: gettext("Rounds"), key: :rounds, type: :integer}, | ||||
|         %{label: gettext("Tags"), key: :tags, type: :tags}, | ||||
|         %{label: gettext("Actions"), key: :actions, sortable: false, type: :actions} | ||||
|       ]) | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       tag_actions: tag_actions, | ||||
|       actions: actions | ||||
|       actions: actions, | ||||
|       pack_count: Ammo.get_ammo_groups_count_for_containers(containers, current_user), | ||||
|       round_count: Ammo.get_round_count_for_containers(containers, current_user) | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
| @@ -101,8 +104,6 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|  | ||||
|   @spec get_row_data_for_container(Container.t(), columns :: [map()], extra_data :: map) :: map() | ||||
|   defp get_row_data_for_container(container, columns, extra_data) do | ||||
|     container = container |> Repo.preload([:ammo_groups, :tags]) | ||||
|  | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> {key, get_value_for_key(key, container, extra_data)} end) | ||||
|   end | ||||
| @@ -121,21 +122,27 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:packs, container, _extra_data) do | ||||
|     container |> Containers.get_container_ammo_group_count!() | ||||
|   defp get_value_for_key(:packs, %{id: container_id}, %{pack_count: pack_count}) do | ||||
|     pack_count |> Map.get(container_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:rounds, container, _extra_data) do | ||||
|     container |> Containers.get_container_rounds!() | ||||
|   defp get_value_for_key(:rounds, %{id: container_id}, %{round_count: round_count}) do | ||||
|     round_count |> Map.get(container_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:tags, container, %{tag_actions: tag_actions}) do | ||||
|     assigns = %{tag_actions: tag_actions, container: container} | ||||
|  | ||||
|     {container.tags |> Enum.map(fn %{name: name} -> name end), | ||||
|     tag_names = | ||||
|       container.tags | ||||
|       |> Enum.map(fn %{name: name} -> name end) | ||||
|       |> Enum.sort() | ||||
|       |> Enum.join(" ") | ||||
|  | ||||
|     {tag_names, | ||||
|      ~H""" | ||||
|      <div class="flex flex-wrap justify-center items-center"> | ||||
|        <TagCard.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||
|        <.simple_tag_card :for={tag <- @container.tags} :if={@container.tags} tag={tag} /> | ||||
|  | ||||
|        <%= render_slot(@tag_actions, @container) %> | ||||
|      </div> | ||||
|   | ||||
							
								
								
									
										149
									
								
								lib/cannery_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								lib/cannery_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| defmodule CanneryWeb.CoreComponents do | ||||
|   @moduledoc """ | ||||
|   Provides core UI components. | ||||
|   """ | ||||
|   use Phoenix.Component | ||||
|   import CanneryWeb.{Gettext, ViewHelpers} | ||||
|   alias Cannery.{Accounts, Accounts.Invite, Accounts.User} | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup} | ||||
|   alias Cannery.{Containers.Container, Containers.Tag} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|   alias CanneryWeb.Router.Helpers, as: Routes | ||||
|   alias Phoenix.LiveView.{JS, Rendered} | ||||
|  | ||||
|   embed_templates "core_components/*" | ||||
|  | ||||
|   attr :title_content, :string, default: nil | ||||
|   attr :current_user, User, default: nil | ||||
|  | ||||
|   def topbar(assigns) | ||||
|  | ||||
|   attr :return_to, :string, required: true | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @doc """ | ||||
|   Renders a live component inside a modal. | ||||
|  | ||||
|   The rendered modal receives a `:return_to` option to properly update | ||||
|   the URL when the modal is closed. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}> | ||||
|         <.live_component | ||||
|           module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} | ||||
|           id={@<%= schema.singular %>.id || :new} | ||||
|           title={@page_title} | ||||
|           action={@live_action} | ||||
|           return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)} | ||||
|           <%= schema.singular %>: @<%= schema.singular %> | ||||
|         /> | ||||
|       </.modal> | ||||
|   """ | ||||
|   def modal(assigns) | ||||
|  | ||||
|   defp hide_modal(js \\ %JS{}) do | ||||
|     js | ||||
|     |> JS.hide(to: "#modal", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||
|   end | ||||
|  | ||||
|   attr :action, :string, required: true | ||||
|   attr :value, :boolean, required: true | ||||
|   attr :id, :string, default: nil | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @doc """ | ||||
|   A toggle button element that can be directed to a liveview or a | ||||
|   live_component's `handle_event/3`. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   <.toggle_button action="my_liveview_action" value={@some_value}> | ||||
|     <span>Toggle me!</span> | ||||
|   </.toggle_button> | ||||
|   <.toggle_button action="my_live_component_action" target={@myself} value={@some_value}> | ||||
|     <span>Whatever you want</span> | ||||
|   </.toggle_button> | ||||
|   """ | ||||
|   def toggle_button(assigns) | ||||
|  | ||||
|   attr :container, Container, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:tag_actions) | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @spec container_card(assigns :: map()) :: Rendered.t() | ||||
|   def container_card(assigns) | ||||
|  | ||||
|   attr :tag, Tag, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def tag_card(assigns) | ||||
|  | ||||
|   attr :tag, Tag, required: true | ||||
|  | ||||
|   def simple_tag_card(assigns) | ||||
|  | ||||
|   attr :ammo_group, AmmoGroup, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   attr :original_count, :integer, default: nil | ||||
|   attr :cpr, :integer, default: nil | ||||
|   attr :last_used_date, Date, default: nil | ||||
|   attr :container, Container, default: nil | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   def ammo_group_card(assigns) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
|  | ||||
|   attr :user, User, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def user_card(assigns) | ||||
|  | ||||
|   attr :invite, Invite, required: true | ||||
|   attr :use_count, :integer, default: nil | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:inner_block) | ||||
|   slot(:code_actions) | ||||
|  | ||||
|   def invite_card(assigns) | ||||
|  | ||||
|   attr :content, :string, required: true | ||||
|   attr :filename, :string, default: "qrcode", doc: "filename without .png extension" | ||||
|   attr :image_class, :string, default: "w-64 h-max" | ||||
|   attr :width, :integer, default: 384, doc: "width of png to generate" | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a downloadable QR Code element | ||||
|   """ | ||||
|   def qr_code(assigns) | ||||
|  | ||||
|   attr :id, :string, required: true | ||||
|   attr :date, :any, required: true, doc: "A `Date` struct or nil" | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for a <date> element that renders the Date in the user's | ||||
|   local timezone | ||||
|   """ | ||||
|   def date(assigns) | ||||
|  | ||||
|   attr :id, :string, required: true | ||||
|   attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for a <time> element that renders the naivedatetime in the | ||||
|   user's local timezone | ||||
|   """ | ||||
|   def datetime(assigns) | ||||
|  | ||||
|   @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() | ||||
|   defp cast_datetime(%NaiveDateTime{} = datetime) do | ||||
|     datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) | ||||
|   end | ||||
|  | ||||
|   defp cast_datetime(_datetime), do: "" | ||||
| end | ||||
| @@ -0,0 +1,65 @@ | ||||
| <div | ||||
|   id={"ammo_group-#{@ammo_group.id}"} | ||||
|   class="mx-4 my-2 px-8 py-4 | ||||
|     flex flex-col justify-center items-center | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link"> | ||||
|     <h1 class="title text-xl title-primary-500"> | ||||
|       <%= @ammo_group.ammo_type.name %> | ||||
|     </h1> | ||||
|   </.link> | ||||
|  | ||||
|   <div class="flex flex-col justify-center items-center"> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Count:") %> | ||||
|       <%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %> | ||||
|     </span> | ||||
|  | ||||
|     <span | ||||
|       :if={@original_count && @original_count != @ammo_group.count} | ||||
|       class="rounded-lg title text-lg" | ||||
|     > | ||||
|       <%= gettext("Original Count:") %> | ||||
|       <%= @original_count %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@ammo_group.notes} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Notes:") %> | ||||
|       <%= @ammo_group.notes %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@ammo_group.purchased_on} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Purchased on:") %> | ||||
|       <.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} /> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@last_used_date} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Last used on:") %> | ||||
|       <.date id={"#{@ammo_group.id}-last-used-on"} date={@last_used_date} /> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@ammo_group.price_paid} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Price paid:") %> | ||||
|       <%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@cpr} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("CPR:") %> | ||||
|       <%= gettext("$%{amount}", amount: display_currency(@cpr)) %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@container} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Container:") %> | ||||
|  | ||||
|       <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> | ||||
|         <%= @container.name %> | ||||
|       </.link> | ||||
|     </span> | ||||
|   </div> | ||||
|  | ||||
|   <div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,58 @@ | ||||
| <div | ||||
|   id={"container-#{@container.id}"} | ||||
|   class="overflow-hidden max-w-full mx-4 mb-4 px-8 py-4 | ||||
|     flex flex-col justify-around items-center space-y-4 | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> | ||||
|     <h1 class="px-4 py-2 rounded-lg title text-xl"> | ||||
|       <%= @container.name %> | ||||
|     </h1> | ||||
|   </.link> | ||||
|  | ||||
|   <div class="flex flex-col justify-center items-center space-y-2"> | ||||
|     <span :if={@container.desc} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Description:") %> | ||||
|       <%= @container.desc %> | ||||
|     </span> | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Type:") %> | ||||
|       <%= @container.type %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@container.location} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Location:") %> | ||||
|       <%= @container.location %> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) != 0 do %> | ||||
|       <span class="rounded-lg title text-lg"> | ||||
|         <%= gettext("Packs:") %> | ||||
|         <%= @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) %> | ||||
|       </span> | ||||
|  | ||||
|       <span class="rounded-lg title text-lg"> | ||||
|         <%= gettext("Rounds:") %> | ||||
|         <%= @container |> Ammo.get_round_count_for_container!(@current_user) %> | ||||
|       </span> | ||||
|     <% end %> | ||||
|  | ||||
|     <div | ||||
|       :if={@tag_actions || @container.tags != []} | ||||
|       class="flex flex-wrap justify-center items-center" | ||||
|     > | ||||
|       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> | ||||
|  | ||||
|       <%= if @tag_actions, do: render_slot(@tag_actions) %> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <div | ||||
|     :if={assigns |> Map.has_key?(:inner_block)} | ||||
|     class="flex space-x-4 justify-center items-center" | ||||
|   > | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> | ||||
|   <%= Date.to_iso8601(@date, :extended) %> | ||||
| </time> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> | ||||
|   <%= cast_datetime(@datetime) %> | ||||
| </time> | ||||
| @@ -0,0 +1,46 @@ | ||||
| <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|   border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|   transition-all duration-300 ease-in-out"> | ||||
|   <h1 class="title text-xl"> | ||||
|     <%= @invite.name %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @invite.disabled_at |> is_nil() do %> | ||||
|     <h2 class="title text-md"> | ||||
|       <%= if @invite.uses_left do %> | ||||
|         <%= gettext( | ||||
|           "Uses Left: %{uses_left_count}", | ||||
|           uses_left_count: @invite.uses_left | ||||
|         ) %> | ||||
|       <% else %> | ||||
|         <%= gettext("Uses Left: Unlimited") %> | ||||
|       <% end %> | ||||
|     </h2> | ||||
|   <% else %> | ||||
|     <h2 class="title text-md"> | ||||
|       <%= gettext("Invite Disabled") %> | ||||
|     </h2> | ||||
|   <% end %> | ||||
|  | ||||
|   <.qr_code | ||||
|     content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||
|     filename={@invite.name} | ||||
|   /> | ||||
|  | ||||
|   <h2 :if={@use_count && @use_count != 0} class="title text-md"> | ||||
|     <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> | ||||
|   </h2> | ||||
|  | ||||
|   <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|     <code | ||||
|       id={"code-#{@invite.id}"} | ||||
|       class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" | ||||
|       phx-no-format | ||||
|     ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||
|     <%= if @code_actions, do: render_slot(@code_actions) %> | ||||
|   </div> | ||||
|  | ||||
|   <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										44
									
								
								lib/cannery_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/cannery_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| <.link | ||||
|   patch={@return_to} | ||||
|   id="modal-bg" | ||||
|   class="fade-in fixed z-10 left-0 top-0 | ||||
|     w-full h-full overflow-hidden | ||||
|     p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|   style="background-color: rgba(0,0,0,0.4);" | ||||
|   phx-remove={hide_modal()} | ||||
|   aria-label={gettext("Close modal")} | ||||
| > | ||||
|   <span class="hidden"></span> | ||||
| </.link> | ||||
|  | ||||
| <div | ||||
|   id="modal" | ||||
|   class="fixed z-10 left-0 top-0 pointer-events-none | ||||
|     w-full h-full overflow-hidden | ||||
|     p-4 sm:p-8 flex flex-col justify-center items-center" | ||||
| > | ||||
|   <div | ||||
|     id="modal-content" | ||||
|     class="fade-in-scale w-full max-w-3xl relative | ||||
|       pointer-events-auto overflow-hidden | ||||
|       px-8 py-4 sm:py-8 | ||||
|       flex flex-col justify-start items-center | ||||
|       bg-white border-2 rounded-lg" | ||||
|   > | ||||
|     <.link | ||||
|       patch={@return_to} | ||||
|       id="close" | ||||
|       class="absolute top-8 right-10 | ||||
|         text-gray-500 hover:text-gray-800 | ||||
|         transition-all duration-500 ease-in-out" | ||||
|       phx-remove={hide_modal()} | ||||
|       aria-label={gettext("Close modal")} | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|     </.link> | ||||
|  | ||||
|     <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> | ||||
|       <%= render_slot(@inner_block) %> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <a href={qr_code_image(@content)} download={@filename <> ".png"}> | ||||
|   <img class={@image_class} alt={@filename} src={qr_code_image(@content)} /> | ||||
| </a> | ||||
| @@ -0,0 +1,6 @@ | ||||
| <h1 | ||||
|   class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl" | ||||
|   style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"} | ||||
| > | ||||
|   <%= @tag.name %> | ||||
| </h1> | ||||
| @@ -0,0 +1,9 @@ | ||||
| <div | ||||
|   id={"tag-#{@tag.id}"} | ||||
|   class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <.simple_tag_card tag={@tag} /> | ||||
|   <%= render_slot(@inner_block) %> | ||||
| </div> | ||||
| @@ -0,0 +1,30 @@ | ||||
| <label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> | ||||
|   <input | ||||
|     id={@id || @action} | ||||
|     type="checkbox" | ||||
|     value={@value} | ||||
|     checked={@value} | ||||
|     class="sr-only peer" | ||||
|     aria-labelledby={"#{@id || @action}-label"} | ||||
|     { | ||||
|       if assigns |> Map.has_key?(:target), | ||||
|         do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target}, | ||||
|         else: %{"phx-click": @action, "phx-value-value": @value} | ||||
|     } | ||||
|   /> | ||||
|   <div class="w-11 h-6 bg-gray-300 rounded-full peer | ||||
|     peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 | ||||
|     peer-checked:bg-gray-600 | ||||
|     peer-checked:after:translate-x-full peer-checked:after:border-white | ||||
|     after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300 | ||||
|     after:border after:rounded-full after:h-5 after:w-5 | ||||
|     after:transition-all after:duration-250 after:ease-in-out | ||||
|     transition-colors duration-250 ease-in-out"> | ||||
|   </div> | ||||
|   <span | ||||
|     id={"#{@id || @action}-label"} | ||||
|     class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" | ||||
|   > | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </span> | ||||
| </label> | ||||
							
								
								
									
										130
									
								
								lib/cannery_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								lib/cannery_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | ||||
| <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500"> | ||||
|   <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|     <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|       <.link | ||||
|         navigate={Routes.live_path(Endpoint, HomeLive)} | ||||
|         class="inline mx-2 my-1 leading-5 text-xl text-white" | ||||
|       > | ||||
|         <img | ||||
|           src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|           alt={gettext("Cannery logo")} | ||||
|           class="inline-block h-8 mx-1" | ||||
|         /> | ||||
|         <h1 class="inline hover:underline">Cannery</h1> | ||||
|       </.link> | ||||
|  | ||||
|       <%= if @title_content do %> | ||||
|         <span class="mx-2 my-1"> | ||||
|           | | ||||
|         </span> | ||||
|         <%= @title_content %> | ||||
|       <% end %> | ||||
|     </div> | ||||
|  | ||||
|     <hr class="mb-2 sm:hidden hr-light" /> | ||||
|  | ||||
|     <ul class="flex flex-row flex-wrap justify-center items-center | ||||
|       text-lg text-white text-ellipsis"> | ||||
|       <%= if @current_user do %> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.tag_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Tags") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.container_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Containers") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Catalog") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Ammo") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.range_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Range") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.invite_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Invites") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_settings_path(Endpoint, :edit)} | ||||
|             class="text-white hover:underline truncate" | ||||
|           > | ||||
|             <%= @current_user.email %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_session_path(Endpoint, :delete)} | ||||
|             method="delete" | ||||
|             data-confirm={dgettext("prompts", "Are you sure you want to log out?")} | ||||
|             aria-label={gettext("Log out")} | ||||
|           > | ||||
|             <i class="fas fa-sign-out-alt"></i> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li | ||||
|           :if={ | ||||
|             @current_user |> Accounts.is_already_admin?() and | ||||
|               function_exported?(Routes, :live_dashboard_path, 2) | ||||
|           } | ||||
|           class="mx-2 my-1" | ||||
|         > | ||||
|           <.link | ||||
|             navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||
|             class="text-white hover:underline" | ||||
|             aria-label={gettext("Live Dashboard")} | ||||
|           > | ||||
|             <i class="fas fa-gauge"></i> | ||||
|           </.link> | ||||
|         </li> | ||||
|       <% else %> | ||||
|         <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_registration_path(Endpoint, :new)} | ||||
|             class="text-white hover:underline truncate" | ||||
|           > | ||||
|             <%= dgettext("actions", "Register") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_session_path(Endpoint, :new)} | ||||
|             class="text-white hover:underline truncate" | ||||
|           > | ||||
|             <%= dgettext("actions", "Log in") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|       <% end %> | ||||
|     </ul> | ||||
|   </div> | ||||
| </nav> | ||||
| @@ -0,0 +1,36 @@ | ||||
| <div | ||||
|   id={"user-#{@user.id}"} | ||||
|   class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||
|     <%= @user.email %> | ||||
|   </h1> | ||||
|  | ||||
|   <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||
|     <p> | ||||
|       <%= if @user.confirmed_at do %> | ||||
|         <%= gettext( | ||||
|           "User was confirmed at%{confirmed_datetime}", | ||||
|           confirmed_datetime: "" | ||||
|         ) %> | ||||
|         <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} /> | ||||
|       <% else %> | ||||
|         <%= gettext("Email unconfirmed") %> | ||||
|       <% end %> | ||||
|     </p> | ||||
|  | ||||
|     <p> | ||||
|       <%= gettext( | ||||
|         "User registered on%{registered_datetime}", | ||||
|         registered_datetime: "" | ||||
|       ) %> | ||||
|       <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} /> | ||||
|     </p> | ||||
|   </h3> | ||||
|  | ||||
|   <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -1,70 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.InviteCard do | ||||
|   @moduledoc """ | ||||
|   Display card for an invite | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Accounts.{Invite, Invites, User} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   attr :invite, Invite, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:inner_block) | ||||
|   slot(:code_actions) | ||||
|  | ||||
|   def invite_card(%{invite: invite, current_user: current_user} = assigns) do | ||||
|     assigns = | ||||
|       assigns | ||||
|       |> assign(:use_count, Invites.get_use_count(invite, current_user)) | ||||
|       |> assign_new(:code_actions, fn -> [] end) | ||||
|  | ||||
|     ~H""" | ||||
|     <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|       border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|       transition-all duration-300 ease-in-out"> | ||||
|       <h1 class="title text-xl"> | ||||
|         <%= @invite.name %> | ||||
|       </h1> | ||||
|  | ||||
|       <%= if @invite.disabled_at |> is_nil() do %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= if @invite.uses_left do %> | ||||
|             <%= gettext( | ||||
|               "Uses Left: %{uses_left_count}", | ||||
|               uses_left_count: @invite.uses_left | ||||
|             ) %> | ||||
|           <% else %> | ||||
|             <%= gettext("Uses Left: Unlimited") %> | ||||
|           <% end %> | ||||
|         </h2> | ||||
|       <% else %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= gettext("Invite Disabled") %> | ||||
|         </h2> | ||||
|       <% end %> | ||||
|  | ||||
|       <.qr_code | ||||
|         content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||
|         filename={@invite.name} | ||||
|       /> | ||||
|  | ||||
|       <h2 :if={@use_count != 0} class="title text-md"> | ||||
|         <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> | ||||
|       </h2> | ||||
|  | ||||
|       <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|         <code | ||||
|           id={"code-#{@invite.id}"} | ||||
|           class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" | ||||
|           phx-no-format | ||||
|         ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||
|         <%= render_slot(@code_actions) %> | ||||
|       </div> | ||||
|  | ||||
|       <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -6,6 +6,7 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
| @@ -51,10 +52,9 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|       |> case do | ||||
|         {:ok, _ammo_group} -> | ||||
|           prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|       end | ||||
|  | ||||
| @@ -64,10 +64,10 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|   @impl true | ||||
|   def render(%{containers: containers} = assigns) do | ||||
|     columns = [ | ||||
|       %{label: gettext("Container"), key: "name"}, | ||||
|       %{label: gettext("Type"), key: "type"}, | ||||
|       %{label: gettext("Location"), key: "location"}, | ||||
|       %{label: nil, key: "actions", sortable: false} | ||||
|       %{label: gettext("Container"), key: :name}, | ||||
|       %{label: gettext("Type"), key: :type}, | ||||
|       %{label: gettext("Location"), key: :location}, | ||||
|       %{label: gettext("Actions"), key: :actions, sortable: false} | ||||
|     ] | ||||
|  | ||||
|     rows = containers |> get_rows_for_containers(assigns, columns) | ||||
| @@ -110,8 +110,8 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   @spec get_row_value_by_key(String.t(), Container.t(), map()) :: any() | ||||
|   defp get_row_value_by_key("actions", container, assigns) do | ||||
|   @spec get_row_value_by_key(atom(), Container.t(), map()) :: any() | ||||
|   defp get_row_value_by_key(:actions, container, assigns) do | ||||
|     assigns = assigns |> Map.put(:container, container) | ||||
|  | ||||
|     ~H""" | ||||
| @@ -129,6 +129,5 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_row_value_by_key(key, container, _assigns), | ||||
|     do: container |> Map.get(key |> String.to_existing_atom()) | ||||
|   defp get_row_value_by_key(key, container, _assigns), do: container |> Map.get(key) | ||||
| end | ||||
|   | ||||
| @@ -3,7 +3,7 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|   A component that displays a list of shot groups | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, ComparableDate} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
| @@ -41,11 +41,16 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|       %{label: gettext("Ammo"), key: :name}, | ||||
|       %{label: gettext("Rounds shot"), key: :count}, | ||||
|       %{label: gettext("Notes"), key: :notes}, | ||||
|       %{label: gettext("Date"), key: :date}, | ||||
|       %{label: nil, key: :actions, sortable: false} | ||||
|       %{label: gettext("Date"), key: :date, type: ComparableDate}, | ||||
|       %{label: gettext("Actions"), key: :actions, sortable: false} | ||||
|     ] | ||||
|  | ||||
|     extra_data = %{current_user: current_user, actions: actions} | ||||
|     ammo_groups = | ||||
|       shot_groups | ||||
|       |> Enum.map(fn %{ammo_group_id: ammo_group_id} -> ammo_group_id end) | ||||
|       |> Ammo.get_ammo_groups(current_user) | ||||
|  | ||||
|     extra_data = %{current_user: current_user, actions: actions, ammo_groups: ammo_groups} | ||||
|  | ||||
|     rows = | ||||
|       shot_groups | ||||
| @@ -79,34 +84,28 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|   @spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) :: | ||||
|           map() | ||||
|   defp get_row_data_for_shot_group(shot_group, columns, extra_data) do | ||||
|     shot_group = shot_group |> Repo.preload(ammo_group: :ammo_type) | ||||
|  | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_row_value(key, shot_group, extra_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   defp get_row_value( | ||||
|          :name, | ||||
|          %{ammo_group: %{ammo_type: %{name: ammo_type_name} = ammo_group}}, | ||||
|          _extra_data | ||||
|        ) do | ||||
|     assigns = %{ammo_group: ammo_group, ammo_type_name: ammo_type_name} | ||||
|   defp get_row_value(:name, %{ammo_group_id: ammo_group_id}, %{ammo_groups: ammo_groups}) do | ||||
|     assigns = %{ammo_group: ammo_group = Map.fetch!(ammo_groups, ammo_group_id)} | ||||
|  | ||||
|     name_block = ~H""" | ||||
|     {ammo_group.ammo_type.name, | ||||
|      ~H""" | ||||
|      <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link"> | ||||
|       <%= @ammo_type_name %> | ||||
|        <%= @ammo_group.ammo_type.name %> | ||||
|      </.link> | ||||
|     """ | ||||
|  | ||||
|     {ammo_type_name, name_block} | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_row_value(:date, %{date: _date} = assigns, _extra_data) do | ||||
|   defp get_row_value(:date, %{date: date} = assigns, _extra_data) do | ||||
|     {date, | ||||
|      ~H""" | ||||
|     <.date date={@date} /> | ||||
|     """ | ||||
|      <.date id={"#{@id}-date"} date={@date} /> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_row_value(:actions, shot_group, %{actions: actions}) do | ||||
|   | ||||
| @@ -33,7 +33,8 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|                 optional(:class) => String.t(), | ||||
|                 optional(:row_class) => String.t(), | ||||
|                 optional(:alternate_row_class) => String.t(), | ||||
|                 optional(:sortable) => false | ||||
|                 optional(:sortable) => false, | ||||
|                 optional(:type) => module() | ||||
|               }), | ||||
|             required(:rows) => | ||||
|               list(%{ | ||||
| @@ -60,7 +61,8 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|         :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode) | ||||
|     type = columns |> Enum.find(%{}, fn %{key: key} -> key == initial_key end) |> Map.get(:type) | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode, type) | ||||
|  | ||||
|     socket = | ||||
|       socket | ||||
| @@ -68,6 +70,7 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|       |> assign( | ||||
|         columns: columns, | ||||
|         rows: rows, | ||||
|         key: initial_key, | ||||
|         last_sort_key: initial_key, | ||||
|         sort_mode: initial_sort_mode | ||||
|       ) | ||||
| @@ -81,7 +84,14 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|   def handle_event( | ||||
|         "sort_by", | ||||
|         %{"sort-key" => key}, | ||||
|         %{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket | ||||
|         %{ | ||||
|           assigns: %{ | ||||
|             columns: columns, | ||||
|             rows: rows, | ||||
|             last_sort_key: last_sort_key, | ||||
|             sort_mode: sort_mode | ||||
|           } | ||||
|         } = socket | ||||
|       ) do | ||||
|     key = key |> String.to_existing_atom() | ||||
|  | ||||
| @@ -92,11 +102,28 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|         {_new_sort_key, _last_sort_mode} -> :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) | ||||
|     type = | ||||
|       columns |> Enum.find(%{}, fn %{key: column_key} -> column_key == key end) |> Map.get(:type) | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode, type) | ||||
|     {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} | ||||
|   end | ||||
|  | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) | ||||
|        when type in [Date, DateTime] do | ||||
|     rows | ||||
|     |> Enum.sort_by( | ||||
|       fn row -> | ||||
|         case row |> Map.get(key) do | ||||
|           {custom_sort_key, _value} -> custom_sort_key | ||||
|           value -> value | ||||
|         end | ||||
|       end, | ||||
|       {sort_mode, type} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, _type) do | ||||
|     rows | ||||
|     |> Enum.sort_by( | ||||
|       fn row -> | ||||
| @@ -108,4 +135,25 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|       sort_mode | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Conditionally composes elements into the columns list, supports maps and | ||||
|   lists. Works tail to front in order for efficiency | ||||
|  | ||||
|       iex> [] | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 3"}, true) | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 2"}, false) | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 1"}) | ||||
|       [%{label: "Column 1"}, %{label: "Column 3"}] | ||||
|  | ||||
|   """ | ||||
|   @spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list() | ||||
|   @spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list() | ||||
|   def maybe_compose_columns(columns, element_or_elements, add? \\ true) | ||||
|  | ||||
|   def maybe_compose_columns(columns, elements, true) when is_list(elements), | ||||
|     do: Enum.concat(elements, columns) | ||||
|  | ||||
|   def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns] | ||||
|   def maybe_compose_columns(columns, _element_or_elements, false), do: columns | ||||
| end | ||||
|   | ||||
| @@ -1,38 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.TagCard do | ||||
|   @moduledoc """ | ||||
|   Display card for a tag | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Tags.Tag | ||||
|  | ||||
|   attr :tag, Tag, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def tag_card(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"tag-#{@tag.id}"} | ||||
|       class="mx-4 mb-4 px-8 py-4 space-x-4 flex justify-center items-center | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <.simple_tag_card tag={@tag} /> | ||||
|       <%= render_slot(@inner_block) %> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   attr :tag, Tag, required: true | ||||
|  | ||||
|   def simple_tag_card(assigns) do | ||||
|     ~H""" | ||||
|     <h1 | ||||
|       class="inline-block break-all mx-2 my-1 px-4 py-2 rounded-lg title text-xl" | ||||
|       style={"color: #{@tag.text_color}; background-color: #{@tag.bg_color}"} | ||||
|     > | ||||
|       <%= @tag.name %> | ||||
|     </h1> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -1,144 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.Topbar do | ||||
|   @moduledoc """ | ||||
|   Component that renders a topbar with user functions/links | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|  | ||||
|   alias Cannery.Accounts | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|  | ||||
|   def topbar(assigns) do | ||||
|     assigns = | ||||
|       %{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns) | ||||
|  | ||||
|     ~H""" | ||||
|     <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400"> | ||||
|       <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|         <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|           <.link | ||||
|             navigate={Routes.live_path(Endpoint, HomeLive)} | ||||
|             class="inline mx-2 my-1 leading-5 text-xl text-white" | ||||
|           > | ||||
|             <img | ||||
|               src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|               alt={gettext("Cannery logo")} | ||||
|               class="inline-block h-8 mx-1" | ||||
|             /> | ||||
|             <h1 class="inline hover:underline">Cannery</h1> | ||||
|           </.link> | ||||
|  | ||||
|           <%= if @title_content do %> | ||||
|             <span class="mx-2 my-1"> | ||||
|               | | ||||
|             </span> | ||||
|             <%= @title_content %> | ||||
|           <% end %> | ||||
|         </div> | ||||
|  | ||||
|         <hr class="mb-2 sm:hidden hr-light" /> | ||||
|  | ||||
|         <ul class="flex flex-row flex-wrap justify-center items-center | ||||
|           text-lg text-white text-ellipsis"> | ||||
|           <%= if @current_user do %> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.tag_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Tags") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.container_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Containers") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Catalog") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Ammo") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.range_index_path(Endpoint, :index)} | ||||
|                 class="text-primary-600 text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Range") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <%= if @current_user.role == :admin do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <.link | ||||
|                   navigate={Routes.invite_index_path(Endpoint, :index)} | ||||
|                   class="text-primary-600 text-white hover:underline" | ||||
|                 > | ||||
|                   <%= gettext("Invites") %> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.user_settings_path(Endpoint, :edit)} | ||||
|                 class="text-primary-600 text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= @current_user.email %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 href={Routes.user_session_path(Endpoint, :delete)} | ||||
|                 method="delete" | ||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to log out?")} | ||||
|               > | ||||
|                 <i class="fas fa-sign-out-alt"></i> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <.link | ||||
|                   navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||
|                   class="text-primary-600 text-white hover:underline" | ||||
|                 > | ||||
|                   <i class="fas fa-gauge"></i> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|           <% else %> | ||||
|             <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.user_registration_path(Endpoint, :new)} | ||||
|                 class="text-primary-600 text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "Register") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.user_session_path(Endpoint, :new)} | ||||
|                 class="text-primary-600 text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "Log in") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|           <% end %> | ||||
|         </ul> | ||||
|       </div> | ||||
|     </nav> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -1,52 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.UserCard do | ||||
|   @moduledoc """ | ||||
|   Display card for a user | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :component | ||||
|   alias Cannery.Accounts.User | ||||
|  | ||||
|   attr :user, User, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def user_card(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"user-#{@user.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||
|         <%= @user.email %> | ||||
|       </h1> | ||||
|  | ||||
|       <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||
|         <p> | ||||
|           <%= if @user.confirmed_at do %> | ||||
|             <%= gettext( | ||||
|               "User was confirmed at%{confirmed_datetime}", | ||||
|               confirmed_datetime: "" | ||||
|             ) %> | ||||
|             <.datetime datetime={@user.confirmed_at} /> | ||||
|           <% else %> | ||||
|             <%= gettext("Email unconfirmed") %> | ||||
|           <% end %> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <%= gettext( | ||||
|             "User registered on%{registered_datetime}", | ||||
|             registered_datetime: "" | ||||
|           ) %> | ||||
|           <.datetime datetime={@user.inserted_at} /> | ||||
|         </p> | ||||
|       </h3> | ||||
|  | ||||
|       <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -3,51 +3,58 @@ defmodule CanneryWeb.ExportController do | ||||
|   alias Cannery.{ActivityLog, Ammo, Containers} | ||||
|  | ||||
|   def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do | ||||
|     ammo_types = | ||||
|       Ammo.list_ammo_types(current_user) | ||||
|       |> Enum.map(fn ammo_type -> | ||||
|         average_cost = ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user) | ||||
|         round_count = ammo_type |> Ammo.get_round_count_for_ammo_type(current_user) | ||||
|         used_count = ammo_type |> Ammo.get_used_count_for_ammo_type(current_user) | ||||
|         ammo_group_count = ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true) | ||||
|     ammo_types = Ammo.list_ammo_types(current_user, :all) | ||||
|     used_counts = ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user) | ||||
|     round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user) | ||||
|     ammo_group_counts = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user) | ||||
|  | ||||
|     total_ammo_group_counts = | ||||
|       ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true) | ||||
|  | ||||
|     average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user) | ||||
|  | ||||
|     ammo_types = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn %{id: ammo_type_id} = ammo_type -> | ||||
|         ammo_type | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "average_cost" => average_cost, | ||||
|           "round_count" => round_count, | ||||
|           "used_count" => used_count, | ||||
|           "ammo_group_count" => ammo_group_count | ||||
|           "average_cost" => Map.get(average_costs, ammo_type_id), | ||||
|           "round_count" => Map.get(round_counts, ammo_type_id, 0), | ||||
|           "used_count" => Map.get(used_counts, ammo_type_id, 0), | ||||
|           "ammo_group_count" => Map.get(ammo_group_counts, ammo_type_id, 0), | ||||
|           "total_ammo_group_count" => Map.get(total_ammo_group_counts, ammo_type_id, 0) | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     ammo_groups = | ||||
|       Ammo.list_ammo_groups(nil, true, current_user) | ||||
|       |> Enum.map(fn ammo_group -> | ||||
|         cpr = ammo_group |> Ammo.get_cpr() | ||||
|         used_count = ammo_group |> Ammo.get_used_count() | ||||
|         original_count = ammo_group |> Ammo.get_original_count() | ||||
|         percentage_remaining = ammo_group |> Ammo.get_percentage_remaining() | ||||
|     ammo_groups = Ammo.list_ammo_groups(nil, :all, current_user, true) | ||||
|     used_counts = ammo_groups |> ActivityLog.get_used_counts(current_user) | ||||
|     original_counts = ammo_groups |> Ammo.get_original_counts(current_user) | ||||
|     cprs = ammo_groups |> Ammo.get_cprs(current_user) | ||||
|     percentages_remaining = ammo_groups |> Ammo.get_percentages_remaining(current_user) | ||||
|  | ||||
|     ammo_groups = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %{id: ammo_group_id} = ammo_group -> | ||||
|         ammo_group | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "used_count" => used_count, | ||||
|           "percentage_remaining" => percentage_remaining, | ||||
|           "original_count" => original_count, | ||||
|           "cpr" => cpr | ||||
|           "used_count" => Map.get(used_counts, ammo_group_id), | ||||
|           "percentage_remaining" => Map.fetch!(percentages_remaining, ammo_group_id), | ||||
|           "original_count" => Map.get(original_counts, ammo_group_id), | ||||
|           "cpr" => Map.get(cprs, ammo_group_id) | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     shot_groups = ActivityLog.list_shot_groups(current_user) | ||||
|     shot_groups = ActivityLog.list_shot_groups(:all, current_user) | ||||
|  | ||||
|     containers = | ||||
|       Containers.list_containers(current_user) | ||||
|       |> Enum.map(fn container -> | ||||
|         ammo_group_count = container |> Containers.get_container_ammo_group_count!() | ||||
|         round_count = container |> Containers.get_container_rounds!() | ||||
|         ammo_group_count = container |> Ammo.get_ammo_groups_count_for_container!(current_user) | ||||
|         round_count = container |> Ammo.get_round_count_for_container!(current_user) | ||||
|  | ||||
|         container | ||||
|         |> Jason.encode!() | ||||
|   | ||||
| @@ -1,4 +1,8 @@ | ||||
| defmodule CanneryWeb.HomeController do | ||||
|   @moduledoc """ | ||||
|   Controller for home page | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :controller | ||||
|  | ||||
|   def index(conn, _params) do | ||||
|   | ||||
| @@ -3,6 +3,7 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts, Accounts.Invites} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   def new(conn, %{"invite" => invite_token}) do | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
| @@ -70,7 +71,7 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|         |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|         |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|       {:error, %Changeset{} = changeset} -> | ||||
|         conn |> render("new.html", changeset: changeset, invite_token: invite_token) | ||||
|     end | ||||
|   end | ||||
|   | ||||
| @@ -26,7 +26,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|       socket = | ||||
|       socket | ||||
|       |> assign(:ammo_group_create_limit, @ammo_group_create_limit) | ||||
|       |> assign(:ammo_types, Ammo.list_ammo_types(current_user)) | ||||
|       |> assign(:ammo_types, Ammo.list_ammo_types(current_user, :all)) | ||||
|       |> assign_new(:containers, fn -> Containers.list_containers(current_user) end) | ||||
|  | ||||
|     params = | ||||
| @@ -44,7 +44,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("validate", %{"ammo_group" => ammo_group_params}, socket) do | ||||
|     {:noreply, socket |> assign_changeset(ammo_group_params)} | ||||
|     {:noreply, socket |> assign_changeset(ammo_group_params, :validate)} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
| @@ -56,6 +56,7 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|   end | ||||
|  | ||||
|   # HTML Helpers | ||||
|  | ||||
|   @spec container_options([Container.t()]) :: [{String.t(), Container.id()}] | ||||
|   defp container_options(containers) do | ||||
|     containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end) | ||||
| @@ -70,35 +71,28 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|  | ||||
|   defp assign_changeset( | ||||
|          %{assigns: %{action: action, ammo_group: ammo_group, current_user: user}} = socket, | ||||
|          ammo_group_params | ||||
|          ammo_group_params, | ||||
|          changeset_action \\ nil | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       cond do | ||||
|         action in [:new, :clone] -> :insert | ||||
|         action == :edit -> :update | ||||
|     default_action = | ||||
|       case action do | ||||
|         create when create in [:new, :clone] -> :insert | ||||
|         :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       cond do | ||||
|         action in [:new, :clone] -> | ||||
|           ammo_type = | ||||
|             if ammo_group_params |> Map.has_key?("ammo_type_id"), | ||||
|               do: ammo_group_params |> Map.get("ammo_type_id") |> Ammo.get_ammo_type!(user), | ||||
|               else: nil | ||||
|  | ||||
|           container = | ||||
|             if ammo_group_params |> Map.has_key?("container_id"), | ||||
|               do: ammo_group_params |> Map.get("container_id") |> Containers.get_container!(user), | ||||
|               else: nil | ||||
|  | ||||
|       case default_action do | ||||
|         :insert -> | ||||
|           ammo_type = maybe_get_ammo_type(ammo_group_params, user) | ||||
|           container = maybe_get_container(ammo_group_params, user) | ||||
|           ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params) | ||||
|  | ||||
|         action == :edit -> | ||||
|         :update -> | ||||
|           ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case changeset |> Changeset.apply_action(changeset_action) do | ||||
|       case changeset |> Changeset.apply_action(changeset_action || default_action) do | ||||
|         {:ok, _data} -> changeset | ||||
|         {:error, changeset} -> changeset | ||||
|       end | ||||
| @@ -106,6 +100,20 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|     socket |> assign(:changeset, changeset) | ||||
|   end | ||||
|  | ||||
|   defp maybe_get_container(%{"container_id" => container_id}, user) | ||||
|        when is_binary(container_id) do | ||||
|     container_id |> Containers.get_container!(user) | ||||
|   end | ||||
|  | ||||
|   defp maybe_get_container(_params_not_found, _user), do: nil | ||||
|  | ||||
|   defp maybe_get_ammo_type(%{"ammo_type_id" => ammo_type_id}, user) | ||||
|        when is_binary(ammo_type_id) do | ||||
|     ammo_type_id |> Ammo.get_ammo_type!(user) | ||||
|   end | ||||
|  | ||||
|   defp maybe_get_ammo_type(_params_not_found, _user), do: nil | ||||
|  | ||||
|   defp save_ammo_group( | ||||
|          %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} = | ||||
|            socket, | ||||
| @@ -146,16 +154,18 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|               multiplier: multiplier | ||||
|             ) | ||||
|  | ||||
|           {:error, changeset} = | ||||
|             changeset | ||||
|             |> Changeset.add_error(:multiplier, error_msg) | ||||
|             |> Changeset.apply_action(:insert) | ||||
|  | ||||
|           socket |> assign(:changeset, changeset) | ||||
|           save_multiplier_error(socket, changeset, error_msg) | ||||
|  | ||||
|         :error -> | ||||
|           error_msg = dgettext("errors", "Could not parse number of copies") | ||||
|           save_multiplier_error(socket, changeset, error_msg) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @spec save_multiplier_error(Socket.t(), Changeset.t(), String.t()) :: Socket.t() | ||||
|   defp save_multiplier_error(socket, changeset, error_msg) do | ||||
|     {:error, changeset} = | ||||
|       changeset | ||||
|       |> Changeset.add_error(:multiplier, error_msg) | ||||
| @@ -164,9 +174,6 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|     socket |> assign(:changeset, changeset) | ||||
|   end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   defp create_multiple( | ||||
|          %{assigns: %{current_user: current_user, return_to: return_to}} = socket, | ||||
|          ammo_group_params, | ||||
|   | ||||
| @@ -49,8 +49,10 @@ | ||||
|  | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       id: "ammo-group-form-notes", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       phx_hook: "MaintainAttrs" | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :notes, "col-span-3 text-center") %> | ||||
|  | ||||
|   | ||||
| @@ -8,11 +8,16 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: search) |> display_ammo_groups()} | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(class: :all, show_used: false, search: search) | ||||
|       |> display_ammo_groups() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: nil) |> display_ammo_groups()} | ||||
|     {:ok, socket |> assign(class: :all, show_used: false, search: nil) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -91,7 +96,6 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "toggle_staged", | ||||
|         %{"ammo_group_id" => id}, | ||||
| @@ -105,12 +109,10 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     {:noreply, socket |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))} | ||||
|   end | ||||
| @@ -122,10 +124,36 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "rifle"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :rifle) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "shotgun"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :shotgun) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "pistol"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :pistol) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => _all}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :all) |> display_ammo_groups()} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_groups( | ||||
|          %{assigns: %{search: search, current_user: current_user, show_used: show_used}} = socket | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              class: class, | ||||
|              search: search, | ||||
|              current_user: current_user, | ||||
|              show_used: show_used | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     ammo_groups = Ammo.list_ammo_groups(search, show_used, current_user) | ||||
|     # get total number of ammo groups to determine whether to display onboarding | ||||
|     # prompts | ||||
|     ammo_groups_count = Ammo.get_ammo_groups_count!(current_user, true) | ||||
|     ammo_groups = Ammo.list_ammo_groups(search, class, current_user, show_used) | ||||
|     ammo_types_count = Ammo.get_ammo_types_count!(current_user) | ||||
|     containers_count = Containers.get_containers_count!(current_user) | ||||
|  | ||||
| @@ -133,7 +161,8 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     |> assign( | ||||
|       ammo_groups: ammo_groups, | ||||
|       ammo_types_count: ammo_types_count, | ||||
|       containers_count: containers_count | ||||
|       containers_count: containers_count, | ||||
|       ammo_groups_count: ammo_groups_count | ||||
|     ) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,14 +3,6 @@ | ||||
|     <%= gettext("Ammo") %> | ||||
|   </h1> | ||||
|  | ||||
|   <h2 | ||||
|     :if={@ammo_groups |> Enum.empty?() and @search |> is_nil()} | ||||
|     class="title text-xl text-primary-600" | ||||
|   > | ||||
|     <%= gettext("No Ammo") %> | ||||
|     <%= display_emoji("😔") %> | ||||
|   </h2> | ||||
|  | ||||
|   <%= cond do %> | ||||
|     <% @containers_count == 0 -> %> | ||||
|       <div class="flex justify-center items-center"> | ||||
| @@ -32,7 +24,12 @@ | ||||
|           <%= dgettext("actions", "add an ammo type first") %> | ||||
|         </.link> | ||||
|       </div> | ||||
|     <% @ammo_groups |> Enum.empty?() and @search |> is_nil() -> %> | ||||
|     <% @ammo_groups_count == 0 -> %> | ||||
|       <h2 class="title text-xl text-primary-600"> | ||||
|         <%= gettext("No ammo") %> | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|  | ||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Add your first box!") %> | ||||
|       </.link> | ||||
| @@ -40,20 +37,46 @@ | ||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Add Ammo") %> | ||||
|       </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|       <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||
|         <.form | ||||
|           :let={f} | ||||
|       for={:search} | ||||
|           for={%{}} | ||||
|           as={:ammo_type} | ||||
|           phx-change="change_class" | ||||
|           phx-submit="change_class" | ||||
|           class="flex items-center" | ||||
|         > | ||||
|           <%= label(f, :class, gettext("Class"), | ||||
|             class: "title text-primary-600 text-lg text-center" | ||||
|           ) %> | ||||
|  | ||||
|           <%= select( | ||||
|             f, | ||||
|             :class, | ||||
|             [ | ||||
|               {gettext("All"), :all}, | ||||
|               {gettext("Rifle"), :rifle}, | ||||
|               {gettext("Shotgun"), :shotgun}, | ||||
|               {gettext("Pistol"), :pistol} | ||||
|             ], | ||||
|             class: "mx-2 my-1 min-w-md input input-primary", | ||||
|             value: @class | ||||
|           ) %> | ||||
|         </.form> | ||||
|  | ||||
|         <.form | ||||
|           :let={f} | ||||
|           for={%{}} | ||||
|           as={:search} | ||||
|           phx-change="search" | ||||
|           phx-submit="search" | ||||
|       class="grow self-stretch flex flex-col items-stretch" | ||||
|       data-qa="ammo_group_search" | ||||
|           class="grow flex items-center" | ||||
|         > | ||||
|           <%= text_input(f, :search_term, | ||||
|         class: "input input-primary", | ||||
|             class: "grow input input-primary", | ||||
|             value: @search, | ||||
|             role: "search", | ||||
|             phx_debounce: 300, | ||||
|             placeholder: gettext("Search ammo") | ||||
|           ) %> | ||||
| @@ -77,6 +100,7 @@ | ||||
|           id="ammo-group-index-table" | ||||
|           ammo_groups={@ammo_groups} | ||||
|           current_user={@current_user} | ||||
|           show_used={@show_used} | ||||
|         > | ||||
|           <:ammo_type :let={%{name: ammo_type_name} = ammo_type}> | ||||
|             <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> | ||||
| @@ -91,7 +115,9 @@ | ||||
|                 phx-click="toggle_staged" | ||||
|                 phx-value-ammo_group_id={ammo_group.id} | ||||
|               > | ||||
|             <%= if ammo_group.staged, do: gettext("Unstage"), else: gettext("Stage") %> | ||||
|                 <%= if ammo_group.staged, | ||||
|                   do: dgettext("actions", "Unstage"), | ||||
|                   else: dgettext("actions", "Stage") %> | ||||
|               </button> | ||||
|  | ||||
|               <.link | ||||
| @@ -102,7 +128,7 @@ | ||||
|               </.link> | ||||
|             </div> | ||||
|           </:range> | ||||
|       <:container :let={%{container: %{name: container_name} = container} = ammo_group}> | ||||
|           <:container :let={{ammo_group, %{name: container_name} = container}}> | ||||
|             <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> | ||||
|               <.link | ||||
|                 navigate={Routes.container_show_path(Endpoint, :show, container)} | ||||
| @@ -115,16 +141,20 @@ | ||||
|                 patch={Routes.ammo_group_index_path(Endpoint, :move, ammo_group)} | ||||
|                 class="mx-2 my-1 text-sm btn btn-primary" | ||||
|               > | ||||
|             <%= gettext("Move ammo") %> | ||||
|                 <%= dgettext("actions", "Move ammo") %> | ||||
|               </.link> | ||||
|             </div> | ||||
|           </:container> | ||||
|       <:actions :let={ammo_group}> | ||||
|           <:actions :let={%{count: ammo_group_count} = ammo_group}> | ||||
|             <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> | ||||
|               <.link | ||||
|                 navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)} | ||||
|                 class="text-primary-600 link" | ||||
|             data-qa={"view-#{ammo_group.id}"} | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "View ammo group of %{ammo_group_count} bullets", | ||||
|                     ammo_group_count: ammo_group_count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|               </.link> | ||||
| @@ -132,7 +162,11 @@ | ||||
|               <.link | ||||
|                 patch={Routes.ammo_group_index_path(Endpoint, :edit, ammo_group)} | ||||
|                 class="text-primary-600 link" | ||||
|             data-qa={"edit-#{ammo_group.id}"} | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Edit ammo group of %{ammo_group_count} bullets", | ||||
|                     ammo_group_count: ammo_group_count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|               </.link> | ||||
| @@ -140,7 +174,11 @@ | ||||
|               <.link | ||||
|                 patch={Routes.ammo_group_index_path(Endpoint, :clone, ammo_group)} | ||||
|                 class="text-primary-600 link" | ||||
|             data-qa={"clone-#{ammo_group.id}"} | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Clone ammo group of %{ammo_group_count} bullets", | ||||
|                     ammo_group_count: ammo_group_count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|               </.link> | ||||
| @@ -151,7 +189,11 @@ | ||||
|                 phx-click="delete" | ||||
|                 phx-value-id={ammo_group.id} | ||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} | ||||
|             data-qa={"delete-#{ammo_group.id}"} | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Delete ammo group of %{ammo_group_count} bullets", | ||||
|                     ammo_group_count: ammo_group_count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|               </.link> | ||||
| @@ -159,10 +201,11 @@ | ||||
|           </:actions> | ||||
|         </.live_component> | ||||
|       <% end %> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= cond do %> | ||||
|   <% @live_action in [:new, :edit, :clone] -> %> | ||||
| <%= case @live_action do %> | ||||
|   <% create when create in [:new, :edit, :clone] -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.AmmoGroupLive.FormComponent} | ||||
| @@ -174,7 +217,7 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% @live_action == :add_shot_group -> %> | ||||
|   <% :add_shot_group -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.AddShotGroupComponent} | ||||
| @@ -186,7 +229,7 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% @live_action == :move -> %> | ||||
|   <% :move -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.MoveAmmoGroupComponent} | ||||
| @@ -198,6 +241,5 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% true -> %> | ||||
|     <%= nil %> | ||||
|   <% _ -> %> | ||||
| <% end %> | ||||
|   | ||||
| @@ -4,8 +4,9 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.ContainerCard | ||||
|   alias Cannery.{ActivityLog, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup, Repo} | ||||
|   alias Cannery.{ActivityLog, ActivityLog.ShotGroup} | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup} | ||||
|   alias Cannery.{ComparableDate, Containers} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
| @@ -28,7 +29,6 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
| @@ -58,7 +58,6 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "toggle_staged", | ||||
|         _params, | ||||
| @@ -70,7 +69,6 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|     {:noreply, socket |> display_ammo_group(ammo_group)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete_shot_group", | ||||
|         %{"id" => id}, | ||||
| @@ -85,30 +83,45 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|   end | ||||
|  | ||||
|   @spec display_ammo_group(Socket.t(), AmmoGroup.t() | AmmoGroup.id()) :: Socket.t() | ||||
|   defp display_ammo_group(socket, %AmmoGroup{} = ammo_group) do | ||||
|     ammo_group = ammo_group |> Repo.preload([:container, :ammo_type, :shot_groups], force: true) | ||||
|  | ||||
|   defp display_ammo_group( | ||||
|          %{assigns: %{current_user: current_user}} = socket, | ||||
|          %AmmoGroup{container_id: container_id} = ammo_group | ||||
|        ) do | ||||
|     columns = [ | ||||
|       %{label: gettext("Rounds shot"), key: :count}, | ||||
|       %{label: gettext("Notes"), key: :notes}, | ||||
|       %{label: gettext("Date"), key: :date}, | ||||
|       %{label: nil, key: :actions, sortable: false} | ||||
|       %{label: gettext("Date"), key: :date, type: ComparableDate}, | ||||
|       %{label: gettext("Actions"), key: :actions, sortable: false} | ||||
|     ] | ||||
|  | ||||
|     shot_groups = ActivityLog.list_shot_groups_for_ammo_group(ammo_group, current_user) | ||||
|  | ||||
|     rows = | ||||
|       ammo_group.shot_groups | ||||
|       shot_groups | ||||
|       |> Enum.map(fn shot_group -> | ||||
|         ammo_group |> get_table_row_for_shot_group(shot_group, columns) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(ammo_group: ammo_group, columns: columns, rows: rows) | ||||
|     socket | ||||
|     |> assign( | ||||
|       ammo_group: ammo_group, | ||||
|       original_count: Ammo.get_original_count(ammo_group, current_user), | ||||
|       percentage_remaining: Ammo.get_percentage_remaining(ammo_group, current_user), | ||||
|       container: container_id && Containers.get_container!(container_id, current_user), | ||||
|       shot_groups: shot_groups, | ||||
|       columns: columns, | ||||
|       rows: rows | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_group(%{assigns: %{current_user: current_user}} = socket, id), | ||||
|     do: display_ammo_group(socket, Ammo.get_ammo_group!(id, current_user)) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
|  | ||||
|   @spec get_table_row_for_shot_group(AmmoGroup.t(), ShotGroup.t(), [map()]) :: map() | ||||
|   defp get_table_row_for_shot_group(ammo_group, %{date: date} = shot_group, columns) do | ||||
|   defp get_table_row_for_shot_group(ammo_group, %{id: id, date: date} = shot_group, columns) do | ||||
|     assigns = %{ammo_group: ammo_group, shot_group: shot_group} | ||||
|  | ||||
|     columns | ||||
| @@ -116,11 +129,11 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|       value = | ||||
|         case key do | ||||
|           :date -> | ||||
|             assigns = %{date: date} | ||||
|             assigns = %{id: id, date: date} | ||||
|  | ||||
|             {date, | ||||
|              ~H""" | ||||
|              <.date date={@date} /> | ||||
|              <.date id={"#{@id}-date"} date={@date} /> | ||||
|              """} | ||||
|  | ||||
|           :actions -> | ||||
| @@ -129,7 +142,11 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|               <.link | ||||
|                 patch={Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, @shot_group)} | ||||
|                 class="text-primary-600 link" | ||||
|                 data-qa={"edit-#{@shot_group.id}"} | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Edit shot group of %{shot_group_count} shots", | ||||
|                     shot_group_count: @shot_group.count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|               </.link> | ||||
| @@ -140,7 +157,11 @@ defmodule CanneryWeb.AmmoGroupLive.Show do | ||||
|                 phx-click="delete_shot_group" | ||||
|                 phx-value-id={@shot_group.id} | ||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")} | ||||
|                 data-qa={"delete-#{@shot_group.id}"} | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Delete shot record of %{shot_group_count} shots", | ||||
|                     shot_group_count: @shot_group.count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|               </.link> | ||||
|   | ||||
| @@ -11,12 +11,12 @@ | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Original count:") %> | ||||
|       <%= Ammo.get_original_count(@ammo_group) %> | ||||
|       <%= @original_count %> | ||||
|     </span> | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Percentage left:") %> | ||||
|       <%= gettext("%{percentage}%", percentage: @ammo_group |> Ammo.get_percentage_remaining()) %> | ||||
|       <%= gettext("%{percentage}%", percentage: @percentage_remaining) %> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @ammo_group.notes do %> | ||||
| @@ -28,23 +28,19 @@ | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Purchased on:") %> | ||||
|       <.date date={@ammo_group.purchased_on} /> | ||||
|       <.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} /> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @ammo_group.price_paid do %> | ||||
|       <span class="rounded-lg title text-lg"> | ||||
|         <%= gettext("Original cost:") %> | ||||
|         <%= gettext("$%{amount}", | ||||
|           amount: @ammo_group.price_paid |> :erlang.float_to_binary(decimals: 2) | ||||
|         ) %> | ||||
|         <%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %> | ||||
|       </span> | ||||
|  | ||||
|       <span class="rounded-lg title text-lg"> | ||||
|         <%= gettext("Current value:") %> | ||||
|         <%= gettext("$%{amount}", | ||||
|           amount: | ||||
|             (@ammo_group.price_paid * Ammo.get_percentage_remaining(@ammo_group) / 100) | ||||
|             |> :erlang.float_to_binary(decimals: 2) | ||||
|           amount: display_currency(@ammo_group.price_paid * @percentage_remaining / 100) | ||||
|         ) %> | ||||
|       </span> | ||||
|     <% end %> | ||||
| @@ -55,7 +51,6 @@ | ||||
|       <.link | ||||
|         navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_group.ammo_type)} | ||||
|         class="mx-4 my-2 btn btn-primary" | ||||
|         data-qa="details" | ||||
|       > | ||||
|         <%= dgettext("actions", "View in Catalog") %> | ||||
|       </.link> | ||||
| @@ -63,7 +58,11 @@ | ||||
|       <.link | ||||
|         patch={Routes.ammo_group_show_path(Endpoint, :edit, @ammo_group)} | ||||
|         class="mx-4 my-2 text-primary-600 link" | ||||
|         data-qa="edit" | ||||
|         aria-label={ | ||||
|           dgettext("actions", "Edit ammo group of %{ammo_group_count} bullets", | ||||
|             ammo_group_count: @ammo_group.count | ||||
|           ) | ||||
|         } | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       </.link> | ||||
| @@ -73,7 +72,11 @@ | ||||
|         class="mx-4 my-2 text-primary-600 link" | ||||
|         phx-click="delete" | ||||
|         data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} | ||||
|         data-qa="delete" | ||||
|         aria-label={ | ||||
|           dgettext("actions", "Delete ammo group of %{ammo_group_count} bullets", | ||||
|             ammo_group_count: @ammo_group.count | ||||
|           ) | ||||
|         } | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|       </.link> | ||||
| @@ -89,9 +92,8 @@ | ||||
|       <.link | ||||
|         patch={Routes.ammo_group_show_path(Endpoint, :move, @ammo_group)} | ||||
|         class="btn btn-primary" | ||||
|         data-qa="move" | ||||
|       > | ||||
|         <%= dgettext("actions", "Move containers") %> | ||||
|         <%= dgettext("actions", "Move ammo") %> | ||||
|       </.link> | ||||
|  | ||||
|       <.link | ||||
| @@ -106,18 +108,18 @@ | ||||
|   <hr class="mb-4 w-full" /> | ||||
|  | ||||
|   <div> | ||||
|     <%= if @ammo_group.container do %> | ||||
|     <%= if @container do %> | ||||
|       <h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl"> | ||||
|         <%= gettext("Stored in") %> | ||||
|       </h1> | ||||
|  | ||||
|       <.container_card container={@ammo_group.container} /> | ||||
|       <.container_card container={@container} current_user={@current_user} /> | ||||
|     <% else %> | ||||
|       <%= gettext("This ammo is not in a container") %> | ||||
|     <% end %> | ||||
|   </div> | ||||
|  | ||||
|   <%= unless @ammo_group.shot_groups |> Enum.empty?() do %> | ||||
|   <%= unless @shot_groups |> Enum.empty?() do %> | ||||
|     <hr class="mb-4 w-full" /> | ||||
|  | ||||
|     <h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl"> | ||||
|   | ||||
| @@ -35,15 +35,18 @@ defmodule CanneryWeb.AmmoTypeLive.FormComponent do | ||||
|          ammo_type_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       cond do | ||||
|         action in [:new, :clone] -> :insert | ||||
|         action == :edit -> :update | ||||
|       case action do | ||||
|         create when create in [:new, :clone] -> :insert | ||||
|         :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       cond do | ||||
|         action in [:new, :clone] -> ammo_type |> AmmoType.create_changeset(user, ammo_type_params) | ||||
|         action == :edit -> ammo_type |> AmmoType.update_changeset(ammo_type_params) | ||||
|       case action do | ||||
|         create when create in [:new, :clone] -> | ||||
|           ammo_type |> AmmoType.create_changeset(user, ammo_type_params) | ||||
|  | ||||
|         :edit -> | ||||
|           ammo_type |> AmmoType.update_changeset(ammo_type_params) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|   | ||||
| @@ -15,91 +15,99 @@ | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :class, gettext("Class"), class: "title text-lg text-primary-600") %> | ||||
|     <%= select( | ||||
|       f, | ||||
|       :class, | ||||
|       [{gettext("Rifle"), :rifle}, {gettext("Shotgun"), :shotgun}, {gettext("Pistol"), :pistol}], | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :class, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= text_input(f, :name, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :name, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :desc, | ||||
|       id: "ammo-type-form-desc", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       phx_hook: "MaintainAttrs" | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :desc, "col-span-3 text-center") %> | ||||
|  | ||||
|     <a | ||||
|       href="https://en.wikipedia.org/wiki/Bullet#Abbreviations" | ||||
|       class="col-span-3 text-center link title text-md text-primary-600" | ||||
|     > | ||||
|       <%= gettext("Example bullet type abbreviations") %> | ||||
|     </a> | ||||
|     <%= label(f, :bullet_type, gettext("Bullet type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :bullet_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("FMJ") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :bullet_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :bullet_core, gettext("Bullet core"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :bullet_core, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Steel") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :bullet_core, "col-span-3 text-center") %> | ||||
|     <h2 class="text-center title text-lg text-primary-600 col-span-3"> | ||||
|       <%= gettext("Dimensions") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= if Changeset.get_field(@changeset, :class) in [:rifle, :pistol] do %> | ||||
|       <%= label(f, :cartridge, gettext("Cartridge"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :cartridge, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: "5.56x46mm NATO" | ||||
|         maxlength: 255, | ||||
|         placeholder: gettext("5.56x46mm NATO") | ||||
|       ) %> | ||||
|       <%= error_tag(f, :cartridge, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :cartridge, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <%= label(f, :caliber, gettext("Caliber"), class: "title text-lg text-primary-600") %> | ||||
|     <%= label( | ||||
|       f, | ||||
|       :caliber, | ||||
|       if(Changeset.get_field(@changeset, :class) == :shotgun, | ||||
|         do: gettext("Gauge"), | ||||
|         else: gettext("Caliber") | ||||
|       ), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= text_input(f, :caliber, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: ".223" | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext(".223") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :caliber, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :case_material, gettext("Case material"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :case_material, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Brass") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :jacket_type, gettext("Jacket type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :jacket_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Bimetal") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :muzzle_velocity, gettext("Muzzle velocity"), | ||||
|     <%= if Changeset.get_field(@changeset, :class) == :shotgun do %> | ||||
|       <%= label(f, :unfired_length, gettext("Unfired shell length"), | ||||
|         class: "title text-lg text-primary-600" | ||||
|       ) %> | ||||
|     <%= number_input(f, :muzzle_velocity, | ||||
|       step: "1", | ||||
|       <%= text_input(f, :unfired_length, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|       min: 1 | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|     <%= error_tag(f, :muzzle_velocity, "col-span-3 text-center") %> | ||||
|       <%= error_tag(f, :unfired_length, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :powder_type, gettext("Powder type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :powder_type, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= error_tag(f, :powder_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :powder_grains_per_charge, gettext("Powder grains per charge"), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= number_input(f, :powder_grains_per_charge, | ||||
|       step: "1", | ||||
|       <%= label(f, :brass_height, gettext("Brass height"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :brass_height, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|       min: 1 | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|     <%= error_tag(f, :powder_grains_per_charge, "col-span-3 text-center") %> | ||||
|       <%= error_tag(f, :brass_height, "col-span-3 text-center") %> | ||||
|  | ||||
|       <%= label(f, :chamber_size, gettext("Chamber size"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :chamber_size, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :chamber_size, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :unfired_length, value: nil) %> | ||||
|       <%= hidden_input(f, :brass_height, value: nil) %> | ||||
|       <%= hidden_input(f, :chamber_size, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <h2 class="text-center title text-lg text-primary-600 col-span-3"> | ||||
|       <%= gettext("Projectile") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= label(f, :grains, gettext("Grains"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :grains, | ||||
| @@ -109,27 +117,202 @@ | ||||
|     ) %> | ||||
|     <%= error_tag(f, :grains, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label f, :bullet_type, class: "flex title text-lg text-primary-600 space-x-2" do %> | ||||
|       <p><%= gettext("Bullet type") %></p> | ||||
|  | ||||
|       <.link | ||||
|         href="https://shootersreference.com/reloadingdata/bullet_abbreviations/" | ||||
|         class="link" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <i class="fas fa-md fa-external-link-alt"></i> | ||||
|       </.link> | ||||
|     <% end %> | ||||
|     <%= text_input(f, :bullet_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("FMJ") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :bullet_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label( | ||||
|       f, | ||||
|       :bullet_core, | ||||
|       if(Changeset.get_field(@changeset, :class) == :shotgun, | ||||
|         do: gettext("Slug core"), | ||||
|         else: gettext("Bullet core") | ||||
|       ), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= text_input(f, :bullet_core, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Steel") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :bullet_core, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= if Changeset.get_field(@changeset, :class) in [:rifle, :pistol] do %> | ||||
|       <%= label(f, :jacket_type, gettext("Jacket type"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :jacket_type, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255, | ||||
|         placeholder: gettext("Bimetal") | ||||
|       ) %> | ||||
|       <%= error_tag(f, :jacket_type, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :jacket_type, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <%= label(f, :case_material, gettext("Case material"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :case_material, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Brass") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= if Changeset.get_field(@changeset, :class) == :shotgun do %> | ||||
|       <%= label(f, :wadding, gettext("Wadding"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :wadding, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :wadding, "col-span-3 text-center") %> | ||||
|  | ||||
|       <%= label(f, :shot_type, gettext("Shot type"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :shot_type, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255, | ||||
|         placeholder: gettext("Target, bird, buck, etc") | ||||
|       ) %> | ||||
|       <%= error_tag(f, :shot_type, "col-span-3 text-center") %> | ||||
|  | ||||
|       <%= label(f, :shot_material, gettext("Shot material"), | ||||
|         class: "title text-lg text-primary-600" | ||||
|       ) %> | ||||
|       <%= text_input(f, :shot_material, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :shot_material, "col-span-3 text-center") %> | ||||
|  | ||||
|       <%= label(f, :shot_size, gettext("Shot size"), class: "title text-lg text-primary-600") %> | ||||
|       <%= text_input(f, :shot_size, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :shot_size, "col-span-3 text-center") %> | ||||
|  | ||||
|       <%= label(f, :load_grains, gettext("Load grains"), class: "title text-lg text-primary-600") %> | ||||
|       <%= number_input(f, :load_grains, | ||||
|         step: "1", | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         min: 1 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :load_grains, "col-span-3 text-center") %> | ||||
|  | ||||
|       <%= label(f, :shot_charge_weight, gettext("Shot charge weight"), | ||||
|         class: "title text-lg text-primary-600" | ||||
|       ) %> | ||||
|       <%= text_input(f, :shot_charge_weight, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :shot_charge_weight, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :wadding, value: nil) %> | ||||
|       <%= hidden_input(f, :shot_type, value: nil) %> | ||||
|       <%= hidden_input(f, :shot_material, value: nil) %> | ||||
|       <%= hidden_input(f, :shot_size, value: nil) %> | ||||
|       <%= hidden_input(f, :load_grains, value: nil) %> | ||||
|       <%= hidden_input(f, :shot_charge_weight, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <h2 class="text-center title text-lg text-primary-600 col-span-3"> | ||||
|       <%= gettext("Powder") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= label(f, :powder_type, gettext("Powder type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :powder_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :powder_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= if Changeset.get_field(@changeset, :class) in [:rifle, :pistol] do %> | ||||
|       <%= label(f, :powder_grains_per_charge, gettext("Powder grains per charge"), | ||||
|         class: "title text-lg text-primary-600" | ||||
|       ) %> | ||||
|       <%= number_input(f, :powder_grains_per_charge, | ||||
|         step: "1", | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         min: 1 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :powder_grains_per_charge, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :powder_grains_per_charge, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <%= label(f, :pressure, gettext("Pressure"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :pressure, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: "+P" | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("+P") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :pressure, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= if Changeset.get_field(@changeset, :class) == :shotgun do %> | ||||
|       <%= label(f, :dram_equivalent, gettext("Dram equivalent"), | ||||
|         class: "title text-lg text-primary-600" | ||||
|       ) %> | ||||
|       <%= text_input(f, :dram_equivalent, | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         maxlength: 255 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :dram_equivalent, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :dram_equivalent, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <%= if Changeset.get_field(@changeset, :class) in [:rifle, :pistol] do %> | ||||
|       <%= label(f, :muzzle_velocity, gettext("Muzzle velocity"), | ||||
|         class: "title text-lg text-primary-600" | ||||
|       ) %> | ||||
|       <%= number_input(f, :muzzle_velocity, | ||||
|         step: "1", | ||||
|         class: "text-center col-span-2 input input-primary", | ||||
|         min: 1 | ||||
|       ) %> | ||||
|       <%= error_tag(f, :muzzle_velocity, "col-span-3 text-center") %> | ||||
|     <% else %> | ||||
|       <%= hidden_input(f, :muzzle_velocity, value: nil) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <h2 class="text-center title text-lg text-primary-600 col-span-3"> | ||||
|       <%= gettext("Primer") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= label(f, :primer_type, gettext("Primer type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :primer_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: "Boxer" | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Boxer") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :primer_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :firing_type, gettext("Firing type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :firing_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: "Centerfire" | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Centerfire") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :firing_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <h2 class="text-center title text-lg text-primary-600 col-span-3"> | ||||
|       <%= gettext("Attributes") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= label(f, :tracer, gettext("Tracer"), class: "title text-lg text-primary-600") %> | ||||
|     <%= checkbox(f, :tracer, class: "text-center col-span-2 checkbox") %> | ||||
|     <%= error_tag(f, :tracer, "col-span-3 text-center") %> | ||||
| @@ -146,12 +329,22 @@ | ||||
|     <%= checkbox(f, :corrosive, class: "text-center col-span-2 checkbox") %> | ||||
|     <%= error_tag(f, :corrosive, "col-span-3 text-center") %> | ||||
|  | ||||
|     <h2 class="text-center title text-lg text-primary-600 col-span-3"> | ||||
|       <%= gettext("Manufacturer") %> | ||||
|     </h2> | ||||
|  | ||||
|     <%= label(f, :manufacturer, gettext("Manufacturer"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :manufacturer, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= text_input(f, :manufacturer, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :manufacturer, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :upc, gettext("UPC"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :upc, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= text_input(f, :upc, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :upc, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Save"), | ||||
|   | ||||
| @@ -8,11 +8,11 @@ defmodule CanneryWeb.AmmoTypeLive.Index do | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: search) |> list_ammo_types()} | ||||
|     {:ok, socket |> assign(class: :all, show_used: false, search: search) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: nil) |> list_ammo_types()} | ||||
|     {:ok, socket |> assign(class: :all, show_used: false, search: nil) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -69,28 +69,46 @@ defmodule CanneryWeb.AmmoTypeLive.Index do | ||||
|   @impl true | ||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     %{name: name} = Ammo.get_ammo_type!(id, current_user) |> Ammo.delete_ammo_type!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: name) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     {:noreply, | ||||
|      socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :search, search_term))} | ||||
|     search_path = Routes.ammo_type_index_path(Endpoint, :search, search_term) | ||||
|     {:noreply, socket |> push_patch(to: search_path)} | ||||
|   end | ||||
|  | ||||
|   defp list_ammo_types(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     socket |> assign(ammo_types: Ammo.list_ammo_types(search, current_user)) | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "rifle"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :rifle) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "shotgun"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :shotgun) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "pistol"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :pistol) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => _all}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :all) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   defp list_ammo_types( | ||||
|          %{assigns: %{class: class, search: search, current_user: current_user}} = socket | ||||
|        ) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       ammo_types: Ammo.list_ammo_types(search, current_user, class), | ||||
|       ammo_types_count: Ammo.get_ammo_types_count!(current_user) | ||||
|     ) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <%= gettext("Catalog") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @ammo_types |> Enum.empty?() and @search |> is_nil() do %> | ||||
|   <%= if @ammo_types_count == 0 do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No Ammo types") %> | ||||
|       <%= display_emoji("😔") %> | ||||
| @@ -17,18 +17,43 @@ | ||||
|       <%= dgettext("actions", "New Ammo type") %> | ||||
|     </.link> | ||||
|  | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={:search} | ||||
|         for={%{}} | ||||
|         as={:ammo_type} | ||||
|         phx-change="change_class" | ||||
|         phx-submit="change_class" | ||||
|         class="flex items-center" | ||||
|       > | ||||
|         <%= label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center") %> | ||||
|  | ||||
|         <%= select( | ||||
|           f, | ||||
|           :class, | ||||
|           [ | ||||
|             {gettext("All"), :all}, | ||||
|             {gettext("Rifle"), :rifle}, | ||||
|             {gettext("Shotgun"), :shotgun}, | ||||
|             {gettext("Pistol"), :pistol} | ||||
|           ], | ||||
|           class: "mx-2 my-1 min-w-md input input-primary", | ||||
|           value: @class | ||||
|         ) %> | ||||
|       </.form> | ||||
|  | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={%{}} | ||||
|         as={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         data-qa="ammo_type_search" | ||||
|         class="grow flex items-center" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           class: "grow input input-primary", | ||||
|           value: @search, | ||||
|           role: "search", | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search catalog") | ||||
|         ) %> | ||||
| @@ -54,13 +79,16 @@ | ||||
|         ammo_types={@ammo_types} | ||||
|         current_user={@current_user} | ||||
|         show_used={@show_used} | ||||
|         class={@class} | ||||
|       > | ||||
|         <:actions :let={ammo_type}> | ||||
|           <div class="px-4 py-2 space-x-4 flex justify-center items-center"> | ||||
|             <.link | ||||
|               navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"view-#{ammo_type.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "View %{ammo_type_name}", ammo_type_name: ammo_type.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|             </.link> | ||||
| @@ -68,7 +96,9 @@ | ||||
|             <.link | ||||
|               patch={Routes.ammo_type_index_path(Endpoint, :edit, ammo_type)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"edit-#{ammo_type.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: ammo_type.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
| @@ -76,7 +106,9 @@ | ||||
|             <.link | ||||
|               patch={Routes.ammo_type_index_path(Endpoint, :clone, ammo_type)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"clone-#{ammo_type.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Clone %{ammo_type_name}", ammo_type_name: ammo_type.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|             </.link> | ||||
| @@ -93,7 +125,9 @@ | ||||
|                   name: ammo_type.name | ||||
|                 ) | ||||
|               } | ||||
|               data-qa={"delete-#{ammo_type.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Delete %{ammo_type_name}", ammo_type_name: ammo_type.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
|   | ||||
| @@ -4,44 +4,16 @@ defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.AmmoGroupCard | ||||
|   alias Cannery.{Ammo, Ammo.AmmoType} | ||||
|   alias Cannery.{ActivityLog, Ammo, Ammo.AmmoType, Containers} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   @fields_list [ | ||||
|     %{label: gettext("Bullet type:"), key: :bullet_type, type: :string}, | ||||
|     %{label: gettext("Bullet core:"), key: :bullet_core, type: :string}, | ||||
|     %{label: gettext("Cartridge:"), key: :cartridge, type: :string}, | ||||
|     %{label: gettext("Caliber:"), key: :caliber, type: :string}, | ||||
|     %{label: gettext("Case material:"), key: :case_material, type: :string}, | ||||
|     %{label: gettext("Jacket type:"), key: :jacket_type, type: :string}, | ||||
|     %{label: gettext("Muzzle velocity:"), key: :muzzle_velocity, type: :string}, | ||||
|     %{label: gettext("Powder type:"), key: :powder_type, type: :string}, | ||||
|     %{label: gettext("Powder grains per charge:"), key: :powder_grains_per_charge, type: :string}, | ||||
|     %{label: gettext("Grains:"), key: :grains, type: :string}, | ||||
|     %{label: gettext("Pressure:"), key: :pressure, type: :string}, | ||||
|     %{label: gettext("Primer type:"), key: :primer_type, type: :string}, | ||||
|     %{label: gettext("Firing type:"), key: :firing_type, type: :string}, | ||||
|     %{label: gettext("Tracer:"), key: :tracer, type: :boolean}, | ||||
|     %{label: gettext("Incendiary:"), key: :incendiary, type: :boolean}, | ||||
|     %{label: gettext("Blank:"), key: :blank, type: :boolean}, | ||||
|     %{label: gettext("Corrosive:"), key: :corrosive, type: :boolean}, | ||||
|     %{label: gettext("Manufacturer:"), key: :manufacturer, type: :string}, | ||||
|     %{label: gettext("UPC:"), key: :upc, type: :string} | ||||
|   ] | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: true)} | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, %{assigns: %{live_action: live_action}} = socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: live_action == :table)} | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _params, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(view_table: live_action == :table) | ||||
|       |> display_ammo_type(id) | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   def handle_params(%{"id" => id}, _params, socket) do | ||||
|     {:noreply, socket |> display_ammo_type(id)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -58,32 +30,21 @@ defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "toggle_table", | ||||
|         _params, | ||||
|         %{assigns: %{view_table: view_table, ammo_type: ammo_type}} = socket | ||||
|       ) do | ||||
|     new_path = | ||||
|       if view_table, | ||||
|         do: Routes.ammo_type_show_path(Endpoint, :show, ammo_type), | ||||
|         else: Routes.ammo_type_show_path(Endpoint, :table, ammo_type) | ||||
|  | ||||
|     {:noreply, socket |> push_patch(to: new_path)} | ||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||
|     {:noreply, socket |> assign(:view_table, !view_table)} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type( | ||||
|          %{assigns: %{live_action: live_action, current_user: current_user, show_used: show_used}} = | ||||
|            socket, | ||||
|          %AmmoType{} = ammo_type | ||||
|          %AmmoType{name: ammo_type_name} = ammo_type | ||||
|        ) do | ||||
|     fields_to_display = | ||||
|       @fields_list | ||||
|     custom_fields? = | ||||
|       fields_to_display(ammo_type) | ||||
|       |> Enum.any?(fn %{key: field, type: type} -> | ||||
|         default_value = | ||||
|           case type do | ||||
| @@ -94,14 +55,56 @@ defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|         ammo_type |> Map.get(field) != default_value | ||||
|       end) | ||||
|  | ||||
|     ammo_groups = ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used) | ||||
|  | ||||
|     [ | ||||
|       original_counts, | ||||
|       used_packs_count, | ||||
|       historical_packs_count, | ||||
|       used_rounds, | ||||
|       historical_round_count | ||||
|     ] = | ||||
|       if show_used do | ||||
|         [ | ||||
|           ammo_groups |> Ammo.get_original_counts(current_user), | ||||
|           ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user), | ||||
|           ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true), | ||||
|           ammo_type |> ActivityLog.get_used_count_for_ammo_type(current_user), | ||||
|           ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user) | ||||
|         ] | ||||
|       else | ||||
|         [nil, nil, nil, nil, nil] | ||||
|       end | ||||
|  | ||||
|     page_title = | ||||
|       case live_action do | ||||
|         :show -> ammo_type_name | ||||
|         :edit -> gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name) | ||||
|       end | ||||
|  | ||||
|     containers = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %{container_id: container_id} -> container_id end) | ||||
|       |> Containers.get_containers(current_user) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: page_title(live_action, ammo_type), | ||||
|       page_title: page_title, | ||||
|       ammo_type: ammo_type, | ||||
|       ammo_groups: ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used), | ||||
|       avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type!(current_user), | ||||
|       fields_list: @fields_list, | ||||
|       fields_to_display: fields_to_display | ||||
|       ammo_groups: ammo_groups, | ||||
|       containers: containers, | ||||
|       cprs: ammo_groups |> Ammo.get_cprs(current_user), | ||||
|       last_used_dates: ammo_groups |> ActivityLog.get_last_used_dates(current_user), | ||||
|       avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type(current_user), | ||||
|       rounds: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user), | ||||
|       original_counts: original_counts, | ||||
|       used_rounds: used_rounds, | ||||
|       historical_round_count: historical_round_count, | ||||
|       packs_count: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user), | ||||
|       used_packs_count: used_packs_count, | ||||
|       historical_packs_count: historical_packs_count, | ||||
|       fields_to_display: fields_to_display(ammo_type), | ||||
|       custom_fields?: custom_fields? | ||||
|     ) | ||||
|   end | ||||
|  | ||||
| @@ -113,9 +116,48 @@ defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|     socket |> display_ammo_type(ammo_type) | ||||
|   end | ||||
|  | ||||
|   defp page_title(action, %{name: ammo_type_name}) when action in [:show, :table], | ||||
|     do: ammo_type_name | ||||
|  | ||||
|   defp page_title(:edit, %{name: ammo_type_name}), | ||||
|     do: gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name) | ||||
|   defp fields_to_display(%AmmoType{class: class}) do | ||||
|     [ | ||||
|       %{label: gettext("Cartridge:"), key: :cartridge, type: :string}, | ||||
|       %{ | ||||
|         label: if(class == :shotgun, do: gettext("Gauge:"), else: gettext("Caliber:")), | ||||
|         key: :caliber, | ||||
|         type: :string | ||||
|       }, | ||||
|       %{label: gettext("Unfired length:"), key: :unfired_length, type: :string}, | ||||
|       %{label: gettext("Brass height:"), key: :brass_height, type: :string}, | ||||
|       %{label: gettext("Chamber size:"), key: :chamber_size, type: :string}, | ||||
|       %{label: gettext("Grains:"), key: :grains, type: :string}, | ||||
|       %{label: gettext("Bullet type:"), key: :bullet_type, type: :string}, | ||||
|       %{label: gettext("Bullet core:"), key: :bullet_core, type: :string}, | ||||
|       %{label: gettext("Jacket type:"), key: :jacket_type, type: :string}, | ||||
|       %{label: gettext("Case material:"), key: :case_material, type: :string}, | ||||
|       %{label: gettext("Wadding:"), key: :wadding, type: :string}, | ||||
|       %{label: gettext("Shot type:"), key: :shot_type, type: :string}, | ||||
|       %{label: gettext("Shot material:"), key: :shot_material, type: :string}, | ||||
|       %{label: gettext("Shot size:"), key: :shot_size, type: :string}, | ||||
|       %{label: gettext("Load grains:"), key: :load_grains, type: :string}, | ||||
|       %{label: gettext("Shot charge weight:"), key: :shot_charge_weight, type: :string}, | ||||
|       %{label: gettext("Powder type:"), key: :powder_type, type: :string}, | ||||
|       %{ | ||||
|         label: gettext("Powder grains per charge:"), | ||||
|         key: :powder_grains_per_charge, | ||||
|         type: :string | ||||
|       }, | ||||
|       %{label: gettext("Pressure:"), key: :pressure, type: :string}, | ||||
|       %{label: gettext("Dram equivalent:"), key: :dram_equivalent, type: :string}, | ||||
|       %{label: gettext("Muzzle velocity:"), key: :muzzle_velocity, type: :string}, | ||||
|       %{label: gettext("Primer type:"), key: :primer_type, type: :string}, | ||||
|       %{label: gettext("Firing type:"), key: :firing_type, type: :string}, | ||||
|       %{label: gettext("Tracer:"), key: :tracer, type: :boolean}, | ||||
|       %{label: gettext("Incendiary:"), key: :incendiary, type: :boolean}, | ||||
|       %{label: gettext("Blank:"), key: :blank, type: :boolean}, | ||||
|       %{label: gettext("Corrosive:"), key: :corrosive, type: :boolean}, | ||||
|       %{label: gettext("Manufacturer:"), key: :manufacturer, type: :string}, | ||||
|       %{label: gettext("UPC:"), key: :upc, type: :string} | ||||
|     ] | ||||
|   end | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|     <.link | ||||
|       patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)} | ||||
|       class="text-primary-600 link" | ||||
|       data-qa="edit" | ||||
|       aria-label={dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: @ammo_type.name)} | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|     </.link> | ||||
| @@ -32,7 +32,9 @@ | ||||
|           name: @ammo_type.name | ||||
|         ) | ||||
|       } | ||||
|       data-qa="delete" | ||||
|       aria-label={ | ||||
|         dgettext("actions", "Delete %{ammo_type_name}", ammo_type_name: @ammo_type.name) | ||||
|       } | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|     </.link> | ||||
| @@ -40,9 +42,26 @@ | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <%= if @fields_to_display do %> | ||||
|   <%= if @ammo_type.class || @custom_fields? do %> | ||||
|     <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|       <%= for %{label: label, key: key, type: type} <- @fields_list do %> | ||||
|       <h3 class="title text-lg"> | ||||
|         <%= gettext("Class") %> | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
|         <%= case @ammo_type.class do %> | ||||
|           <% :shotgun -> %> | ||||
|             <%= gettext("Shotgun") %> | ||||
|           <% :rifle -> %> | ||||
|             <%= gettext("Rifle") %> | ||||
|           <% :pistol -> %> | ||||
|             <%= gettext("Pistol") %> | ||||
|           <% _ -> %> | ||||
|             <%= gettext("None specified") %> | ||||
|         <% end %> | ||||
|       </span> | ||||
|  | ||||
|       <%= for %{label: label, key: key, type: type} <- @fields_to_display do %> | ||||
|         <%= if @ammo_type |> Map.get(key) do %> | ||||
|           <h3 class="title text-lg"> | ||||
|             <%= label %> | ||||
| @@ -69,15 +88,16 @@ | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_round_count_for_ammo_type(@current_user) %> | ||||
|       <%= @rounds %> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @show_used do %> | ||||
|       <h3 class="title text-lg"> | ||||
|         <%= gettext("Used rounds:") %> | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_used_count_for_ammo_type(@current_user) %> | ||||
|         <%= @used_rounds %> | ||||
|       </span> | ||||
|  | ||||
|       <h3 class="title text-lg"> | ||||
| @@ -85,27 +105,25 @@ | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_historical_count_for_ammo_type(@current_user) %> | ||||
|         <%= @historical_round_count %> | ||||
|       </span> | ||||
|   </div> | ||||
|     <% end %> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Packs:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user) %> | ||||
|       <%= @packs_count %> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @show_used do %> | ||||
|       <h3 class="title text-lg"> | ||||
|         <%= gettext("Used packs:") %> | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_used_ammo_groups_count_for_type(@current_user) %> | ||||
|         <%= @used_packs_count %> | ||||
|       </span> | ||||
|  | ||||
|       <h3 class="title text-lg"> | ||||
| @@ -113,19 +131,16 @@ | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
|       <%= @ammo_type |> Ammo.get_ammo_groups_count_for_type(@current_user, true) %> | ||||
|         <%= @historical_packs_count %> | ||||
|       </span> | ||||
|   </div> | ||||
|     <% end %> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> | ||||
|     <h3 class="title text-lg"> | ||||
|       <%= gettext("Added on:") %> | ||||
|     </h3> | ||||
|  | ||||
|     <span class="text-primary-600"> | ||||
|       <.datetime datetime={@ammo_type.inserted_at} /> | ||||
|       <.datetime id={"#{@ammo_type.id}-inserted-at"} datetime={@ammo_type.inserted_at} /> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @avg_cost_per_round do %> | ||||
| @@ -134,9 +149,7 @@ | ||||
|       </h3> | ||||
|  | ||||
|       <span class="text-primary-600"> | ||||
|         <%= gettext("$%{amount}", | ||||
|           amount: @avg_cost_per_round |> :erlang.float_to_binary(decimals: 2) | ||||
|         ) %> | ||||
|         <%= gettext("$%{amount}", amount: display_currency(@avg_cost_per_round)) %> | ||||
|       </span> | ||||
|     <% else %> | ||||
|       <h3 class="mx-8 my-4 title text-lg text-primary-600 col-span-2"> | ||||
| @@ -174,8 +187,9 @@ | ||||
|           id="ammo-type-show-table" | ||||
|           ammo_groups={@ammo_groups} | ||||
|           current_user={@current_user} | ||||
|           show_used={@show_used} | ||||
|         > | ||||
|           <:container :let={%{container: %{name: container_name} = container}}> | ||||
|           <:container :let={{_ammo_group, %{name: container_name} = container}}> | ||||
|             <.link | ||||
|               navigate={Routes.container_show_path(Endpoint, :show, container)} | ||||
|               class="mx-2 my-1 link" | ||||
| @@ -183,13 +197,32 @@ | ||||
|               <%= container_name %> | ||||
|             </.link> | ||||
|           </:container> | ||||
|           <:actions :let={%{count: ammo_group_count} = ammo_group}> | ||||
|             <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> | ||||
|               <.link | ||||
|                 navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)} | ||||
|                 class="text-primary-600 link" | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "View ammo group of %{ammo_group_count} bullets", | ||||
|                     ammo_group_count: ammo_group_count | ||||
|                   ) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|               </.link> | ||||
|             </div> | ||||
|           </:actions> | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="flex flex-wrap justify-center items-stretch"> | ||||
|           <.ammo_group_card | ||||
|             :for={ammo_group <- @ammo_groups} | ||||
|             :for={%{id: ammo_group_id, container_id: container_id} = ammo_group <- @ammo_groups} | ||||
|             ammo_group={ammo_group} | ||||
|             show_container={true} | ||||
|             original_count={@original_counts && Map.fetch!(@original_counts, ammo_group_id)} | ||||
|             cpr={Map.get(@cprs, ammo_group_id)} | ||||
|             last_used_date={Map.get(@last_used_dates, ammo_group_id)} | ||||
|             current_user={@current_user} | ||||
|             container={Map.fetch!(@containers, container_id)} | ||||
|           /> | ||||
|         </div> | ||||
|       <% end %> | ||||
|   | ||||
| @@ -4,25 +4,41 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container, Repo, Tags, Tags.Tag} | ||||
|   alias Cannery.{Accounts.User, Containers} | ||||
|   alias Cannery.Containers.{Container, Tag} | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{:container => Container.t(), :current_user => User.t(), optional(any) => any}, | ||||
|           %{ | ||||
|             :container => Container.t(), | ||||
|             :current_path => String.t(), | ||||
|             :current_user => User.t(), | ||||
|             optional(any) => any | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{container: container, current_user: current_user} = assigns, socket) do | ||||
|     tags = Tags.list_tags(current_user) | ||||
|     container = container |> Repo.preload(:tags) | ||||
|     {:ok, socket |> assign(assigns) |> assign(tags: tags, container: container)} | ||||
|   def update( | ||||
|         %{container: _container, current_path: _current_path, current_user: current_user} = | ||||
|           assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     tags = Containers.list_tags(current_user) | ||||
|     {:ok, socket |> assign(assigns) |> assign(:tags, tags)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "save", | ||||
|         %{"tag" => %{"tag_id" => tag_id}}, | ||||
|         %{assigns: %{tags: tags, container: container, current_user: current_user}} = socket | ||||
|         %{ | ||||
|           assigns: %{ | ||||
|             tags: tags, | ||||
|             container: container, | ||||
|             current_user: current_user, | ||||
|             current_path: current_path | ||||
|           } | ||||
|         } = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do | ||||
| @@ -32,19 +48,24 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do | ||||
|  | ||||
|         %{name: tag_name} = tag -> | ||||
|           _container_tag = Containers.add_tag!(container, tag, current_user) | ||||
|           container = container |> Repo.preload(:tags, force: true) | ||||
|           prompt = dgettext("prompts", "%{name} added successfully", name: tag_name) | ||||
|           socket |> put_flash(:info, prompt) |> assign(container: container) | ||||
|           socket |> put_flash(:info, prompt) |> push_patch(to: current_path) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete", | ||||
|         %{"tag-id" => tag_id}, | ||||
|         %{assigns: %{tags: tags, container: container, current_user: current_user}} = socket | ||||
|         %{ | ||||
|           assigns: %{ | ||||
|             tags: tags, | ||||
|             container: container, | ||||
|             current_user: current_user, | ||||
|             current_path: current_path | ||||
|           } | ||||
|         } = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       case tags |> Enum.find(fn %{id: id} -> tag_id == id end) do | ||||
| @@ -54,9 +75,8 @@ defmodule CanneryWeb.ContainerLive.EditTagsComponent do | ||||
|  | ||||
|         %{name: tag_name} = tag -> | ||||
|           _container_tag = Containers.remove_tag!(container, tag, current_user) | ||||
|           container = container |> Repo.preload(:tags, force: true) | ||||
|           prompt = dgettext("prompts", "%{name} removed successfully", name: tag_name) | ||||
|           socket |> put_flash(:info, prompt) |> assign(container: container) | ||||
|           socket |> put_flash(:info, prompt) |> push_patch(to: current_path) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   | ||||
| @@ -36,7 +36,8 @@ | ||||
|  | ||||
|     <.form | ||||
|       :let={f} | ||||
|       for={:tag} | ||||
|       for={%{}} | ||||
|       as={:tag} | ||||
|       id="add-tag-to-container-form" | ||||
|       class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|       phx-target={@myself} | ||||
|   | ||||
| @@ -35,17 +35,17 @@ defmodule CanneryWeb.ContainerLive.FormComponent do | ||||
|          container_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       cond do | ||||
|         action in [:new, :clone] -> :insert | ||||
|         action == :edit -> :update | ||||
|       case action do | ||||
|         create when create in [:new, :clone] -> :insert | ||||
|         :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       cond do | ||||
|         action in [:new, :clone] -> | ||||
|       case action do | ||||
|         create when create in [:new, :clone] -> | ||||
|           container |> Container.create_changeset(user, container_params) | ||||
|  | ||||
|         action == :edit -> | ||||
|         :edit -> | ||||
|           container |> Container.update_changeset(container_params) | ||||
|       end | ||||
|  | ||||
|   | ||||
| @@ -21,30 +21,36 @@ | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: gettext("My cool ammo can") | ||||
|       placeholder: gettext("My cool ammo can"), | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :name, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :desc, | ||||
|       id: "container-form-desc", | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: gettext("Metal ammo can with the anime girl sticker"), | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       placeholder: gettext("Metal ammo can with the anime girl sticker") | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :desc, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :type, | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: gettext("Magazine, Clip, Ammo Box, etc") | ||||
|       placeholder: gettext("Magazine, Clip, Ammo Box, etc"), | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :location, | ||||
|       id: "container-form-location", | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: gettext("On the bookshelf"), | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       placeholder: gettext("On the bookshelf") | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :location, "col-span-3 text-center") %> | ||||
|  | ||||
|   | ||||
| @@ -4,8 +4,7 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.ContainerCard | ||||
|   alias Cannery.{Containers, Containers.Container, Repo} | ||||
|   alias Cannery.{Containers, Containers.Container} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   @impl true | ||||
| @@ -23,10 +22,7 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     %{name: container_name} = | ||||
|       container = | ||||
|       Containers.get_container!(id, current_user) | ||||
|       |> Repo.preload([:tags, :ammo_groups]) | ||||
|     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||
|  | ||||
|     socket | ||||
|     |> assign(page_title: gettext("Edit %{name}", name: container_name), container: container) | ||||
| @@ -62,9 +58,7 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit_tags, %{"id" => id}) do | ||||
|     %{name: container_name} = | ||||
|       container = | ||||
|       Containers.get_container!(id, current_user) |> Repo.preload([:tags, :ammo_groups]) | ||||
|     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||
|  | ||||
|     page_title = gettext("Edit %{name} tags", name: container_name) | ||||
|     socket |> assign(page_title: page_title, container: container) | ||||
| @@ -106,12 +100,10 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||
|     {:noreply, socket |> assign(:view_table, !view_table) |> display_containers()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))} | ||||
|   end | ||||
| @@ -122,10 +114,6 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|   end | ||||
|  | ||||
|   defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     containers = | ||||
|       Containers.list_containers(search, current_user) | ||||
|       |> Repo.preload([:tags, :ammo_groups]) | ||||
|  | ||||
|     socket |> assign(:containers, containers) | ||||
|     socket |> assign(:containers, Containers.list_containers(search, current_user)) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -17,18 +17,19 @@ | ||||
|       <%= dgettext("actions", "New Container") %> | ||||
|     </.link> | ||||
|  | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={:search} | ||||
|         for={%{}} | ||||
|         as={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         data-qa="container_search" | ||||
|         class="grow flex items-center" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           class: "grow input input-primary", | ||||
|           value: @search, | ||||
|           role: "search", | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search containers") | ||||
|         ) %> | ||||
| @@ -40,7 +41,6 @@ | ||||
|         </span> | ||||
|       </.toggle_button> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
|     <%= if @containers |> Enum.empty?() do %> | ||||
|       <h2 class="title text-xl text-primary-600"> | ||||
| @@ -61,6 +61,9 @@ | ||||
|               <.link | ||||
|                 patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||
|                 class="text-primary-600 link" | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||
|                 } | ||||
|               > | ||||
|                 <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|               </.link> | ||||
| @@ -70,7 +73,9 @@ | ||||
|             <.link | ||||
|               patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||
|               class="text-primary-600 link" | ||||
|             data-qa={"edit-#{container.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
| @@ -78,7 +83,9 @@ | ||||
|             <.link | ||||
|               patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||
|               class="text-primary-600 link" | ||||
|             data-qa={"clone-#{container.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|             </.link> | ||||
| @@ -89,9 +96,13 @@ | ||||
|               phx-click="delete" | ||||
|               phx-value-id={container.id} | ||||
|               data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) | ||||
|                 dgettext("prompts", "Are you sure you want to delete %{name}?", | ||||
|                   name: container.name | ||||
|                 ) | ||||
|               } | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             data-qa={"delete-#{container.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
| @@ -99,12 +110,19 @@ | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|         <.container_card :for={container <- @containers} container={container}> | ||||
|           <.container_card | ||||
|             :for={container <- @containers} | ||||
|             container={container} | ||||
|             current_user={@current_user} | ||||
|           > | ||||
|             <:tag_actions> | ||||
|               <div class="mx-4 my-2"> | ||||
|                 <.link | ||||
|                   patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||
|                   class="text-primary-600 link" | ||||
|                   aria-label={ | ||||
|                     dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||
|                   } | ||||
|                 > | ||||
|                   <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|                 </.link> | ||||
| @@ -113,7 +131,9 @@ | ||||
|             <.link | ||||
|               patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||
|               class="text-primary-600 link" | ||||
|             data-qa={"edit-#{container.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
| @@ -121,7 +141,9 @@ | ||||
|             <.link | ||||
|               patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||
|               class="text-primary-600 link" | ||||
|             data-qa={"clone-#{container.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|             </.link> | ||||
| @@ -132,9 +154,13 @@ | ||||
|               phx-click="delete" | ||||
|               phx-value-id={container.id} | ||||
|               data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) | ||||
|                 dgettext("prompts", "Are you sure you want to delete %{name}?", | ||||
|                   name: container.name | ||||
|                 ) | ||||
|               } | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             data-qa={"delete-#{container.id}"} | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
| @@ -142,12 +168,12 @@ | ||||
|         </div> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <.modal | ||||
|   :if={@live_action in [:new, :edit, :clone]} | ||||
|   return_to={Routes.container_index_path(Endpoint, :index)} | ||||
| > | ||||
| <%= case @live_action do %> | ||||
|   <% modifying when modifying in [:new, :edit, :clone] -> %> | ||||
|     <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.FormComponent} | ||||
|         id={@container.id || :new} | ||||
| @@ -158,14 +184,17 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|  | ||||
| <.modal :if={@live_action == :edit_tags} return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|   <% :edit_tags -> %> | ||||
|     <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|         id={@container.id} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         container={@container} | ||||
|         current_path={Routes.container_index_path(Endpoint, :edit_tags, @container)} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% _ -> %> | ||||
| <% end %> | ||||
|   | ||||
| @@ -4,21 +4,20 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.{AmmoGroupCard, TagCard} | ||||
|   alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container, Repo, Tags} | ||||
|   alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: true)} | ||||
|     do: {:ok, socket |> assign(class: :all, view_table: true)} | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(view_table: true) | ||||
|       |> assign(:view_table, true) | ||||
|       |> render_container(id, current_user) | ||||
|  | ||||
|     {:noreply, socket} | ||||
| @@ -31,7 +30,7 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|         %{assigns: %{container: container, current_user: current_user}} = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       case Tags.get_tag(tag_id, current_user) do | ||||
|       case Containers.get_tag(tag_id, current_user) do | ||||
|         {:ok, tag} -> | ||||
|           _count = Containers.remove_tag!(container, tag, current_user) | ||||
|  | ||||
| @@ -43,14 +42,13 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> render_container() | ||||
|  | ||||
|         {:error, error_string} -> | ||||
|           socket |> put_flash(:error, error_string) | ||||
|         {:error, :not_found} -> | ||||
|           socket |> put_flash(:error, dgettext("errors", "Tag not found")) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete_container", | ||||
|         _params, | ||||
| @@ -84,37 +82,56 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||
|     {:noreply, socket |> assign(:view_table, !view_table) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "rifle"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :rifle) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "shotgun"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :shotgun) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "pistol"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :pistol) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => _all}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :all) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t() | ||||
|   defp render_container( | ||||
|          %{assigns: %{live_action: live_action, show_used: show_used}} = socket, | ||||
|          %{assigns: %{class: class, live_action: live_action}} = socket, | ||||
|          id, | ||||
|          current_user | ||||
|        ) do | ||||
|     %{name: container_name} = | ||||
|       container = | ||||
|       Containers.get_container!(id, current_user) | ||||
|       |> Repo.preload([:tags], force: true) | ||||
|  | ||||
|     ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used) | ||||
|     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||
|     ammo_groups = Ammo.list_ammo_groups_for_container(container, class, current_user) | ||||
|     original_counts = ammo_groups |> Ammo.get_original_counts(current_user) | ||||
|     cprs = ammo_groups |> Ammo.get_cprs(current_user) | ||||
|     last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user) | ||||
|  | ||||
|     page_title = | ||||
|       case live_action do | ||||
|         action when action in [:show, :table] -> container_name | ||||
|         :show -> container_name | ||||
|         :edit -> gettext("Edit %{name}", name: container_name) | ||||
|         :edit_tags -> gettext("Edit %{name} tags", name: container_name) | ||||
|       end | ||||
|  | ||||
|     socket |> assign(container: container, ammo_groups: ammo_groups, page_title: page_title) | ||||
|     socket | ||||
|     |> assign( | ||||
|       container: container, | ||||
|       round_count: Ammo.get_round_count_for_container!(container, current_user), | ||||
|       ammo_groups_count: Ammo.get_ammo_groups_count_for_container!(container, current_user), | ||||
|       ammo_groups: ammo_groups, | ||||
|       original_counts: original_counts, | ||||
|       cprs: cprs, | ||||
|       last_used_dates: last_used_dates, | ||||
|       page_title: page_title | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @spec render_container(Socket.t()) :: Socket.t() | ||||
|   | ||||
| @@ -18,31 +18,21 @@ | ||||
|     <%= @container.location %> | ||||
|   </span> | ||||
|  | ||||
|   <%= unless @ammo_groups |> Enum.empty?() do %> | ||||
|   <span class="rounded-lg title text-lg"> | ||||
|       <%= if @show_used do %> | ||||
|         <%= gettext("Total packs:") %> | ||||
|       <% else %> | ||||
|     <%= gettext("Packs:") %> | ||||
|       <% end %> | ||||
|       <%= Enum.count(@ammo_groups) %> | ||||
|     <%= @ammo_groups_count %> | ||||
|   </span> | ||||
|  | ||||
|   <span class="rounded-lg title text-lg"> | ||||
|       <%= if @show_used do %> | ||||
|         <%= gettext("Total rounds:") %> | ||||
|       <% else %> | ||||
|     <%= gettext("Rounds:") %> | ||||
|       <% end %> | ||||
|       <%= @container |> Containers.get_container_rounds!() %> | ||||
|     <%= @round_count %> | ||||
|   </span> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="flex space-x-4 justify-center items-center text-primary-600"> | ||||
|     <.link | ||||
|       patch={Routes.container_show_path(Endpoint, :edit, @container)} | ||||
|       class="text-primary-600 link" | ||||
|       data-qa="edit" | ||||
|       aria-label={dgettext("actions", "Edit %{container_name}", container_name: @container.name)} | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|     </.link> | ||||
| @@ -54,7 +44,9 @@ | ||||
|       data-confirm={ | ||||
|         dgettext("prompts", "Are you sure you want to delete %{name}?", name: @container.name) | ||||
|       } | ||||
|       data-qa="delete" | ||||
|       aria-label={ | ||||
|         dgettext("actions", "Delete %{container_name}", container_name: @container.name) | ||||
|       } | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|     </.link> | ||||
| @@ -94,11 +86,29 @@ | ||||
|   <hr class="mb-4 hr" /> | ||||
|  | ||||
|   <div class="flex justify-center items-center space-x-4"> | ||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("Show used") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|     <.form | ||||
|       :let={f} | ||||
|       for={%{}} | ||||
|       as={:ammo_type} | ||||
|       phx-change="change_class" | ||||
|       phx-submit="change_class" | ||||
|       class="flex items-center" | ||||
|     > | ||||
|       <%= label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center") %> | ||||
|  | ||||
|       <%= select( | ||||
|         f, | ||||
|         :class, | ||||
|         [ | ||||
|           {gettext("All"), :all}, | ||||
|           {gettext("Rifle"), :rifle}, | ||||
|           {gettext("Shotgun"), :shotgun}, | ||||
|           {gettext("Pistol"), :pistol} | ||||
|         ], | ||||
|         class: "mx-2 my-1 min-w-md input input-primary", | ||||
|         value: @class | ||||
|       ) %> | ||||
|     </.form> | ||||
|  | ||||
|     <.toggle_button action="toggle_table" value={@view_table}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
| @@ -119,6 +129,7 @@ | ||||
|           id="ammo-type-show-table" | ||||
|           ammo_groups={@ammo_groups} | ||||
|           current_user={@current_user} | ||||
|           show_used={false} | ||||
|         > | ||||
|           <:ammo_type :let={%{name: ammo_type_name} = ammo_type}> | ||||
|             <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> | ||||
| @@ -128,17 +139,23 @@ | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="flex flex-wrap justify-center items-stretch"> | ||||
|           <.ammo_group_card :for={ammo_group <- @ammo_groups} ammo_group={ammo_group} /> | ||||
|           <.ammo_group_card | ||||
|             :for={%{id: ammo_group_id} = ammo_group <- @ammo_groups} | ||||
|             ammo_group={ammo_group} | ||||
|             original_count={Map.fetch!(@original_counts, ammo_group_id)} | ||||
|             cpr={Map.get(@cprs, ammo_group_id)} | ||||
|             last_used_date={Map.get(@last_used_dates, ammo_group_id)} | ||||
|             current_user={@current_user} | ||||
|           /> | ||||
|         </div> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| <.modal | ||||
|   :if={@live_action == :edit} | ||||
|   return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
| > | ||||
| <%= case @live_action do %> | ||||
|   <% :edit -> %> | ||||
|     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.FormComponent} | ||||
|         id={@container.id} | ||||
| @@ -149,11 +166,8 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|  | ||||
| <.modal | ||||
|   :if={@live_action == :edit_tags} | ||||
|   return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
| > | ||||
|   <% :edit_tags -> %> | ||||
|     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|         id={@container.id} | ||||
| @@ -161,6 +175,9 @@ | ||||
|         action={@live_action} | ||||
|         container={@container} | ||||
|         return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|         current_path={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% _ -> %> | ||||
| <% end %> | ||||
|   | ||||
| @@ -12,7 +12,6 @@ defmodule CanneryWeb.HomeLive do | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     admins = Accounts.list_users_by_role(:admin) | ||||
|     socket = socket |> assign(page_title: gettext("Home"), admins: admins, version: @version) | ||||
|     {:ok, socket} | ||||
|     {:ok, socket |> assign(page_title: gettext("Home"), admins: admins, version: @version)} | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -17,8 +17,7 @@ | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-4 text-center"> | ||||
|     <li class="flex flex-col justify-center items-center | ||||
|       space-y-2"> | ||||
|     <li class="flex flex-col justify-center items-center space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Easy to Use:") %> | ||||
|       </b> | ||||
| @@ -37,8 +36,7 @@ | ||||
|         <%= gettext("Your data stays with you, period") %> | ||||
|       </p> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center items-center | ||||
|       space-y-2"> | ||||
|     <li class="flex flex-col justify-center items-center space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Simple:") %> | ||||
|       </b> | ||||
| @@ -66,9 +64,13 @@ | ||||
|           </.link> | ||||
|         <% else %> | ||||
|           <div class="flex flex-wrap justify-center space-x-2"> | ||||
|             <a :for={%{email: email} <- @admins} class="hover:underline" href={"mailto:#{email}"}> | ||||
|             <.link | ||||
|               :for={%{email: email} <- @admins} | ||||
|               class="hover:underline" | ||||
|               href={"mailto:#{email}"} | ||||
|             > | ||||
|               <%= email %> | ||||
|             </a> | ||||
|             </.link> | ||||
|           </div> | ||||
|         <% end %> | ||||
|       </p> | ||||
| @@ -77,9 +79,9 @@ | ||||
|     <li class="flex flex-row justify-center space-x-2"> | ||||
|       <b><%= gettext("Registration:") %></b> | ||||
|       <p> | ||||
|         <%= case Application.get_env(:cannery, Cannery.Accounts)[:registration] do | ||||
|           "public" -> gettext("Public Signups") | ||||
|           _ -> gettext("Invite Only") | ||||
|         <%= case Accounts.registration_mode() do | ||||
|           :public -> gettext("Public Signups") | ||||
|           :invite_only -> gettext("Invite Only") | ||||
|         end %> | ||||
|       </p> | ||||
|     </li> | ||||
|   | ||||
| @@ -18,7 +18,10 @@ | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= label(f, :name, gettext("Name"), | ||||
|       class: "title text-lg text-primary-600", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :name, "col-span-3") %> | ||||
|  | ||||
|   | ||||
| @@ -4,24 +4,13 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.{InviteCard, UserCard} | ||||
|   alias Cannery.Accounts | ||||
|   alias Cannery.Accounts.{Invite, Invites} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     socket = | ||||
|       if current_user |> Map.get(:role) == :admin do | ||||
|         socket |> display_invites() | ||||
|       else | ||||
|         prompt = dgettext("errors", "You are not authorized to view this page") | ||||
|         return_to = Routes.live_path(Endpoint, HomeLive) | ||||
|         socket |> put_flash(:error, prompt) |> push_redirect(to: return_to) | ||||
|       end | ||||
|  | ||||
|     {:ok, socket} | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> display_invites()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -30,8 +19,8 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(page_title: gettext("Edit Invite"), invite: Invites.get_invite!(id, current_user)) | ||||
|     invite = Invites.get_invite!(id, current_user) | ||||
|     socket |> assign(page_title: gettext("Edit Invite"), invite: invite) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
| @@ -123,22 +112,17 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("copy_to_clipboard", _params, socket) do | ||||
|     prompt = dgettext("prompts", "Copied to clipboard") | ||||
|     {:noreply, socket |> put_flash(:info, prompt)} | ||||
|     {:noreply, socket |> put_flash(:info, dgettext("prompts", "Copied to clipboard"))} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete_user", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     %{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_invites()} | ||||
|   end | ||||
|  | ||||
| @@ -151,7 +135,8 @@ defmodule CanneryWeb.InviteLive.Index do | ||||
|       |> Map.get(:admin, []) | ||||
|       |> Enum.reject(fn %{id: user_id} -> user_id == current_user.id end) | ||||
|  | ||||
|     use_counts = invites |> Invites.get_use_counts(current_user) | ||||
|     users = all_users |> Map.get(:user, []) | ||||
|     socket |> assign(invites: invites, admins: admins, users: users) | ||||
|     socket |> assign(invites: invites, use_counts: use_counts, admins: admins, users: users) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <div class="w-full flex flex-col space-y-8 justify-center items-center"> | ||||
| <div class="mx-auto flex flex-col justify-center items-center space-y-4 max-w-3xl"> | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|     <%= gettext("Invites") %> | ||||
|   </h1> | ||||
| @@ -18,14 +18,22 @@ | ||||
|     </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|     <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}> | ||||
|   <div class="flex flex-col justify-center items-stretch space-y-4"> | ||||
|     <.invite_card | ||||
|       :for={invite <- @invites} | ||||
|       invite={invite} | ||||
|       current_user={@current_user} | ||||
|       use_count={Map.get(@use_counts, invite.id)} | ||||
|     > | ||||
|       <:code_actions> | ||||
|         <form phx-submit="copy_to_clipboard"> | ||||
|           <button | ||||
|             type="submit" | ||||
|             class="mx-2 my-1 btn btn-primary" | ||||
|             phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")} | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Copy invite link for %{invite_name}", invite_name: invite.name) | ||||
|             } | ||||
|           > | ||||
|             <%= dgettext("actions", "Copy to clipboard") %> | ||||
|           </button> | ||||
| @@ -34,7 +42,9 @@ | ||||
|       <.link | ||||
|         patch={Routes.invite_index_path(Endpoint, :edit, invite)} | ||||
|         class="text-primary-600 link" | ||||
|         data-qa={"edit-#{invite.id}"} | ||||
|         aria-label={ | ||||
|           dgettext("actions", "Edit invite for %{invite_name}", invite_name: invite.name) | ||||
|         } | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       </.link> | ||||
| @@ -49,21 +59,23 @@ | ||||
|             invite_name: invite.name | ||||
|           ) | ||||
|         } | ||||
|         data-qa={"delete-#{invite.id}"} | ||||
|         aria-label={ | ||||
|           dgettext("actions", "Delete invite for %{invite_name}", invite_name: invite.name) | ||||
|         } | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|       </.link> | ||||
|  | ||||
|       <a | ||||
|       <.link | ||||
|         href="#" | ||||
|         class="btn btn-primary" | ||||
|         phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"} | ||||
|         phx-value-id={invite.id} | ||||
|       > | ||||
|         <%= if invite.disabled_at, do: gettext("Enable"), else: gettext("Disable") %> | ||||
|       </a> | ||||
|       </.link> | ||||
|  | ||||
|       <a | ||||
|       <.link | ||||
|         :if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())} | ||||
|         href="#" | ||||
|         class="btn btn-primary" | ||||
| @@ -76,7 +88,7 @@ | ||||
|         } | ||||
|       > | ||||
|         <%= dgettext("actions", "Set Unlimited") %> | ||||
|       </a> | ||||
|       </.link> | ||||
|     </.invite_card> | ||||
|   </div> | ||||
|  | ||||
| @@ -87,7 +99,7 @@ | ||||
|       <%= gettext("Admins") %> | ||||
|     </h1> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|     <div class="flex flex-col justify-center items-stretch space-y-4"> | ||||
|       <.user_card :for={admin <- @admins} user={admin}> | ||||
|         <.link | ||||
|           href="#" | ||||
| @@ -115,7 +127,7 @@ | ||||
|       <%= gettext("Users") %> | ||||
|     </h1> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|     <div class="flex flex-col justify-center items-stretch space-y-4"> | ||||
|       <.user_card :for={user <- @users} user={user}> | ||||
|         <.link | ||||
|           href="#" | ||||
|   | ||||
| @@ -1,128 +0,0 @@ | ||||
| defmodule CanneryWeb.LiveHelpers do | ||||
|   @moduledoc """ | ||||
|   Contains common helper functions for liveviews | ||||
|   """ | ||||
|  | ||||
|   import Phoenix.Component | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   @doc """ | ||||
|   Renders a live component inside a modal. | ||||
|  | ||||
|   The rendered modal receives a `:return_to` option to properly update | ||||
|   the URL when the modal is closed. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}> | ||||
|         <.live_component | ||||
|           module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} | ||||
|           id={@<%= schema.singular %>.id || :new} | ||||
|           title={@page_title} | ||||
|           action={@live_action} | ||||
|           return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)} | ||||
|           <%= schema.singular %>: @<%= schema.singular %> | ||||
|         /> | ||||
|       </.modal> | ||||
|   """ | ||||
|   def modal(assigns) do | ||||
|     ~H""" | ||||
|     <.link | ||||
|       patch={@return_to} | ||||
|       id="modal-bg" | ||||
|       class="fade-in fixed z-10 left-0 top-0 | ||||
|         w-full h-full overflow-hidden | ||||
|         p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|       style="background-color: rgba(0,0,0,0.4);" | ||||
|       phx-remove={hide_modal()} | ||||
|     > | ||||
|       <span class="hidden"></span> | ||||
|     </.link> | ||||
|  | ||||
|     <div | ||||
|       id="modal" | ||||
|       class="fixed z-10 left-0 top-0 pointer-events-none | ||||
|         w-full h-full overflow-hidden | ||||
|         p-4 sm:p-8 flex flex-col justify-center items-center" | ||||
|     > | ||||
|       <div | ||||
|         id="modal-content" | ||||
|         class="fade-in-scale w-full max-w-3xl relative | ||||
|           pointer-events-auto overflow-hidden | ||||
|           px-8 py-4 sm:py-8 | ||||
|           flex flex-col justify-start items-center | ||||
|           bg-white border-2 rounded-lg" | ||||
|       > | ||||
|         <.link | ||||
|           patch={@return_to} | ||||
|           id="close" | ||||
|           class="absolute top-8 right-10 | ||||
|             text-gray-500 hover:text-gray-800 | ||||
|             transition-all duration-500 ease-in-out" | ||||
|           phx-remove={hide_modal()} | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|         </.link> | ||||
|  | ||||
|         <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp hide_modal(js \\ %JS{}) do | ||||
|     js | ||||
|     |> JS.hide(to: "#modal", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A toggle button element that can be directed to a liveview or a | ||||
|   live_component's `handle_event/3`. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   <.toggle_button action="my_liveview_action" value={@some_value}> | ||||
|     <span>Toggle me!</span> | ||||
|   </.toggle_button> | ||||
|   <.toggle_button action="my_live_component_action" target={@myself} value={@some_value}> | ||||
|     <span>Whatever you want</span> | ||||
|   </.toggle_button> | ||||
|   """ | ||||
|   def toggle_button(assigns) do | ||||
|     assigns = assigns |> assign_new(:id, fn -> assigns.action end) | ||||
|  | ||||
|     ~H""" | ||||
|     <label for={@id} class="inline-flex relative items-center cursor-pointer"> | ||||
|       <input | ||||
|         id={@id} | ||||
|         type="checkbox" | ||||
|         value={@value} | ||||
|         checked={@value} | ||||
|         class="sr-only peer" | ||||
|         data-qa={@id} | ||||
|         { | ||||
|           if assigns |> Map.has_key?(:target), | ||||
|             do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target}, | ||||
|             else: %{"phx-click": @action, "phx-value-value": @value} | ||||
|         } | ||||
|       /> | ||||
|       <div class="w-11 h-6 bg-gray-300 rounded-full peer | ||||
|         peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 | ||||
|         peer-checked:bg-gray-600 | ||||
|         peer-checked:after:translate-x-full peer-checked:after:border-white | ||||
|         after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300 | ||||
|         after:border after:rounded-full after:h-5 after:w-5 | ||||
|         after:transition-all after:duration-250 after:ease-in-out | ||||
|         transition-colors duration-250 ease-in-out"> | ||||
|       </div> | ||||
|       <span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </span> | ||||
|     </label> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -5,8 +5,12 @@ defmodule CanneryWeb.RangeLive.FormComponent do | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup} | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   def mount(socket), do: {:ok, socket |> assign(:ammo_group, nil)} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
| @@ -19,28 +23,23 @@ defmodule CanneryWeb.RangeLive.FormComponent do | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update( | ||||
|         %{ | ||||
|           shot_group: %ShotGroup{ammo_group_id: ammo_group_id} = shot_group, | ||||
|           shot_group: %ShotGroup{ammo_group_id: ammo_group_id}, | ||||
|           current_user: current_user | ||||
|         } = assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     changeset = shot_group |> ShotGroup.update_changeset(current_user, %{}) | ||||
|       ) | ||||
|       when is_binary(ammo_group_id) do | ||||
|     ammo_group = Ammo.get_ammo_group!(ammo_group_id, current_user) | ||||
|     {:ok, socket |> assign(assigns) |> assign(ammo_group: ammo_group, changeset: changeset)} | ||||
|     {:ok, socket |> assign(assigns) |> assign(:ammo_group, ammo_group) |> assign_changeset(%{})} | ||||
|   end | ||||
|  | ||||
|   def update(%{shot_group: %ShotGroup{}} = assigns, socket) do | ||||
|     {:ok, socket |> assign(assigns) |> assign_changeset(%{})} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "validate", | ||||
|         %{"shot_group" => shot_group_params}, | ||||
|         %{assigns: %{current_user: current_user, shot_group: shot_group}} = socket | ||||
|       ) do | ||||
|     changeset = | ||||
|       shot_group | ||||
|       |> ShotGroup.update_changeset(current_user, shot_group_params) | ||||
|       |> Map.put(:action, :validate) | ||||
|  | ||||
|     {:noreply, assign(socket, :changeset, changeset)} | ||||
|   def handle_event("validate", %{"shot_group" => shot_group_params}, socket) do | ||||
|     {:noreply, socket |> assign_changeset(shot_group_params, :validate)} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
| @@ -61,4 +60,37 @@ defmodule CanneryWeb.RangeLive.FormComponent do | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   defp assign_changeset( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              action: live_action, | ||||
|              current_user: user, | ||||
|              ammo_group: ammo_group, | ||||
|              shot_group: shot_group | ||||
|            } | ||||
|          } = socket, | ||||
|          shot_group_params, | ||||
|          action \\ nil | ||||
|        ) do | ||||
|     default_action = | ||||
|       case live_action do | ||||
|         :add_shot_group -> :insert | ||||
|         editing when editing in [:edit, :edit_shot_group] -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case default_action do | ||||
|         :insert -> shot_group |> ShotGroup.create_changeset(user, ammo_group, shot_group_params) | ||||
|         :update -> shot_group |> ShotGroup.update_changeset(user, shot_group_params) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case changeset |> Changeset.apply_action(action || default_action) do | ||||
|         {:ok, _data} -> changeset | ||||
|         {:error, changeset} -> changeset | ||||
|       end | ||||
|  | ||||
|     socket |> assign(:changeset, changeset) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -29,8 +29,12 @@ | ||||
|  | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       id: "shot-group-form-notes", | ||||
|       class: "input input-primary col-span-2", | ||||
|       phx_hook: "MaintainAttrs" | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Really great weather"), | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :notes, "col-span-3") %> | ||||
|  | ||||
|   | ||||
| @@ -4,18 +4,17 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.AmmoGroupCard | ||||
|   alias Cannery.{ActivityLog, ActivityLog.ShotGroup, Ammo, Repo} | ||||
|   alias Cannery.{ActivityLog, ActivityLog.ShotGroup, Ammo} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(search: search) |> display_shot_groups()} | ||||
|     {:ok, socket |> assign(class: :all, search: search) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(search: nil) |> display_shot_groups()} | ||||
|     {:ok, socket |> assign(class: :all, search: nil) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
| @@ -81,7 +80,6 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "toggle_staged", | ||||
|         %{"ammo_group_id" => ammo_group_id}, | ||||
| @@ -96,7 +94,6 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.range_index_path(Endpoint, :index))} | ||||
|   end | ||||
| @@ -105,18 +102,39 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|     {:noreply, socket |> push_patch(to: Routes.range_index_path(Endpoint, :search, search_term))} | ||||
|   end | ||||
|  | ||||
|   @spec display_shot_groups(Socket.t()) :: Socket.t() | ||||
|   defp display_shot_groups(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     shot_groups = | ||||
|       ActivityLog.list_shot_groups(search, current_user) | ||||
|       |> Repo.preload(ammo_group: :ammo_type) | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "rifle"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :rifle) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "shotgun"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :shotgun) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => "pistol"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :pistol) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"ammo_type" => %{"class" => _all}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :all) |> display_shot_groups()} | ||||
|   end | ||||
|  | ||||
|   @spec display_shot_groups(Socket.t()) :: Socket.t() | ||||
|   defp display_shot_groups( | ||||
|          %{assigns: %{class: class, search: search, current_user: current_user}} = socket | ||||
|        ) do | ||||
|     shot_groups = ActivityLog.list_shot_groups(search, class, current_user) | ||||
|     ammo_groups = Ammo.list_staged_ammo_groups(current_user) | ||||
|     chart_data = shot_groups |> get_chart_data_for_shot_group() | ||||
|     original_counts = ammo_groups |> Ammo.get_original_counts(current_user) | ||||
|     cprs = ammo_groups |> Ammo.get_cprs(current_user) | ||||
|     last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       ammo_groups: ammo_groups, | ||||
|       original_counts: original_counts, | ||||
|       cprs: cprs, | ||||
|       last_used_dates: last_used_dates, | ||||
|       chart_data: chart_data, | ||||
|       shot_groups: shot_groups | ||||
|     ) | ||||
| @@ -125,7 +143,6 @@ defmodule CanneryWeb.RangeLive.Index do | ||||
|   @spec get_chart_data_for_shot_group([ShotGroup.t()]) :: [map()] | ||||
|   defp get_chart_data_for_shot_group(shot_groups) do | ||||
|     shot_groups | ||||
|     |> Repo.preload(ammo_group: :ammo_type) | ||||
|     |> Enum.group_by(fn %{date: date} -> date end, fn %{count: count} -> count end) | ||||
|     |> Enum.map(fn {date, rounds} -> | ||||
|       sum = Enum.sum(rounds) | ||||
|   | ||||
| @@ -18,7 +18,14 @@ | ||||
|     </.link> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.ammo_group_card :for={ammo_group <- @ammo_groups} ammo_group={ammo_group}> | ||||
|       <.ammo_group_card | ||||
|         :for={%{id: ammo_group_id} = ammo_group <- @ammo_groups} | ||||
|         ammo_group={ammo_group} | ||||
|         original_count={Map.fetch!(@original_counts, ammo_group_id)} | ||||
|         cpr={Map.get(@cprs, ammo_group_id)} | ||||
|         last_used_date={Map.get(@last_used_dates, ammo_group_id)} | ||||
|         current_user={@current_user} | ||||
|       > | ||||
|         <button | ||||
|           type="button" | ||||
|           class="btn btn-primary" | ||||
| @@ -67,18 +74,43 @@ | ||||
|       <%= dgettext("errors", "Your browser does not support the canvas element.") %> | ||||
|     </canvas> | ||||
|  | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={:search} | ||||
|         for={%{}} | ||||
|         as={:ammo_type} | ||||
|         phx-change="change_class" | ||||
|         phx-submit="change_class" | ||||
|         class="flex items-center" | ||||
|       > | ||||
|         <%= label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center") %> | ||||
|  | ||||
|         <%= select( | ||||
|           f, | ||||
|           :class, | ||||
|           [ | ||||
|             {gettext("All"), :all}, | ||||
|             {gettext("Rifle"), :rifle}, | ||||
|             {gettext("Shotgun"), :shotgun}, | ||||
|             {gettext("Pistol"), :pistol} | ||||
|           ], | ||||
|           class: "mx-2 my-1 min-w-md input input-primary", | ||||
|           value: @class | ||||
|         ) %> | ||||
|       </.form> | ||||
|  | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={%{}} | ||||
|         as={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         data-qa="shot_group_search" | ||||
|         class="grow flex items-center" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           class: "grow input input-primary", | ||||
|           value: @search, | ||||
|           role: "search", | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search shot records") | ||||
|         ) %> | ||||
| @@ -102,7 +134,11 @@ | ||||
|             <.link | ||||
|               patch={Routes.range_index_path(Endpoint, :edit, shot_group)} | ||||
|               class="text-primary-600 link" | ||||
|               data-qa={"edit-#{shot_group.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Edit shot record of %{shot_group_count} shots", | ||||
|                   shot_group_count: shot_group.count | ||||
|                 ) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
| @@ -115,7 +151,11 @@ | ||||
|               data-confirm={ | ||||
|                 dgettext("prompts", "Are you sure you want to delete this shot record?") | ||||
|               } | ||||
|               data-qa={"delete-#{shot_group.id}"} | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Delete shot record of %{shot_group_count} shots", | ||||
|                   shot_group_count: shot_group.count | ||||
|                 ) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
| @@ -126,7 +166,9 @@ | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <.modal :if={@live_action == :edit} return_to={Routes.range_index_path(Endpoint, :index)}> | ||||
| <%= case @live_action do %> | ||||
|   <% :edit -> %> | ||||
|     <.modal return_to={Routes.range_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.RangeLive.FormComponent} | ||||
|         id={@shot_group.id} | ||||
| @@ -137,11 +179,8 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|  | ||||
| <.modal | ||||
|   :if={@live_action == :add_shot_group} | ||||
|   return_to={Routes.range_index_path(Endpoint, :index)} | ||||
| > | ||||
|   <% :add_shot_group -> %> | ||||
|     <.modal return_to={Routes.range_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.AddShotGroupComponent} | ||||
|         id={:new} | ||||
| @@ -152,3 +191,5 @@ | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% _ -> %> | ||||
| <% end %> | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| defmodule CanneryWeb.TagLive.FormComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can update or create an Cannery.Tags.Tag | ||||
|   Livecomponent that can update or create an Cannery.Containers.Tag | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.Tags | ||||
|   alias Cannery.{Accounts.User, Tags.Tag} | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Tag} | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
| @@ -56,7 +55,7 @@ defmodule CanneryWeb.TagLive.FormComponent do | ||||
|          tag_params | ||||
|        ) do | ||||
|     socket = | ||||
|       case Tags.update_tag(tag, tag_params, current_user) do | ||||
|       case Containers.update_tag(tag, tag_params, current_user) do | ||||
|         {:ok, %{name: tag_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: tag_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
| @@ -74,7 +73,7 @@ defmodule CanneryWeb.TagLive.FormComponent do | ||||
|          tag_params | ||||
|        ) do | ||||
|     socket = | ||||
|       case Tags.create_tag(tag_params, current_user) do | ||||
|       case Containers.create_tag(tag_params, current_user) do | ||||
|         {:ok, %{name: tag_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: tag_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|   | ||||
| @@ -19,7 +19,7 @@ | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2", maxlength: 255) %> | ||||
|     <%= error_tag(f, :name, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :bg_color, gettext("Background color"), class: "title text-lg text-primary-600") %> | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| defmodule CanneryWeb.TagLive.Index do | ||||
|   @moduledoc """ | ||||
|   Liveview to show a Cannery.Tags.Tag index | ||||
|   Liveview to show a Cannery.Containers.Tag index | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   import CanneryWeb.Components.TagCard | ||||
|   alias Cannery.{Tags, Tags.Tag} | ||||
|   alias CanneryWeb.{Endpoint, ViewHelpers} | ||||
|   alias Cannery.{Containers, Containers.Tag} | ||||
|   alias CanneryWeb.ViewHelpers | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
| @@ -26,7 +25,7 @@ defmodule CanneryWeb.TagLive.Index do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit Tag"), | ||||
|       tag: Tags.get_tag!(id, current_user) | ||||
|       tag: Containers.get_tag!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
| @@ -60,12 +59,13 @@ defmodule CanneryWeb.TagLive.Index do | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     %{name: tag_name} = Tags.get_tag!(id, current_user) |> Tags.delete_tag!(current_user) | ||||
|     %{name: tag_name} = | ||||
|       Containers.get_tag!(id, current_user) |> Containers.delete_tag!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: tag_name) | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_tags()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.tag_index_path(Endpoint, :index))} | ||||
|   end | ||||
| @@ -75,6 +75,6 @@ defmodule CanneryWeb.TagLive.Index do | ||||
|   end | ||||
|  | ||||
|   defp display_tags(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     socket |> assign(tags: Tags.list_tags(search, current_user)) | ||||
|     socket |> assign(tags: Containers.list_tags(search, current_user)) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -18,20 +18,20 @@ | ||||
|     <.link patch={Routes.tag_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "New Tag") %> | ||||
|     </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|       for={:search} | ||||
|         for={%{}} | ||||
|         as={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|       class="grow self-stretch flex flex-col items-stretch" | ||||
|       data-qa="tag_search" | ||||
|         class="grow flex items-center" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|         class: "input input-primary", | ||||
|           class: "grow input input-primary", | ||||
|           value: @search, | ||||
|           role: "search", | ||||
|           phx_debounce: 300, | ||||
|           placeholder: gettext("Search tags") | ||||
|         ) %> | ||||
| @@ -49,7 +49,7 @@ | ||||
|           <.link | ||||
|             patch={Routes.tag_index_path(Endpoint, :edit, tag)} | ||||
|             class="text-primary-600 link" | ||||
|           data-qa={"edit-#{tag.id}"} | ||||
|             aria-label={dgettext("actions", "Edit %{tag_name}", tag_name: tag.name)} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
| @@ -62,13 +62,14 @@ | ||||
|             data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: tag.name) | ||||
|             } | ||||
|           data-qa={"delete-#{tag.id}"} | ||||
|             aria-label={dgettext("actions", "Delete %{tag_name}", tag_name: tag.name)} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </.tag_card> | ||||
|       </div> | ||||
|     <% end %> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <.modal :if={@live_action in [:new, :edit]} return_to={Routes.tag_index_path(Endpoint, :index)}> | ||||
|   | ||||
| @@ -77,7 +77,6 @@ defmodule CanneryWeb.Router do | ||||
|  | ||||
|     live "/type/:id", AmmoTypeLive.Show, :show | ||||
|     live "/type/:id/edit", AmmoTypeLive.Show, :edit | ||||
|     live "/type/:id/table", AmmoTypeLive.Show, :table | ||||
|  | ||||
|     live "/containers", ContainerLive.Index, :index | ||||
|     live "/containers/new", ContainerLive.Index, :new | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user