Compare commits

...

133 Commits
0.8.2 ... 0.8.6

Author SHA1 Message Date
355752598c show link to ammo pack in ammo pack table while viewing ammo type 2023-03-19 15:09:44 -04:00
03f8a2e8a7 remove all n+1 queries for real this time 2023-03-19 15:05:09 -04:00
071eb1b3c9 fix some values not being sorted in tables properly 2023-03-19 14:31:53 -04:00
2987e4ff37 hide more ammo group table fields when not viewing historical information 2023-03-19 14:11:01 -04:00
ca81924ebe fix ammo type table not displaying correct information 2023-03-19 14:07:23 -04:00
40e4f6fe0a remove :table path 2023-03-19 13:37:28 -04:00
213dcca973 fix duplicate entries showing up 2023-03-19 13:28:56 -04:00
b32edd581d fix accessibility issues 2023-03-19 12:35:26 -04:00
2e372ca2ab hide historical ammo type information until show_used is toggled 2023-03-19 11:43:13 -04:00
fd0bac3bbf fix tables unable to sort on nil dates 2023-03-19 11:19:55 -04:00
f83fbc5d99 add links to readme 2023-03-19 00:41:39 -04:00
daab051026 remove unnecessary auth check on invite page 2023-03-19 00:23:59 -04:00
440dc5061b fix textareas resizing when typing in 2023-03-18 22:14:04 -04:00
c0d2c69144 run npm audit fix 2023-03-18 22:02:27 -04:00
7a7359fa66 run npx npm-check-updates -u 2023-03-18 22:01:07 -04:00
9e8fd00d65 add ncu as dev dependency 2023-03-18 21:56:21 -04:00
f5f72b53e6 use hooks for datetime, remove alpinejs 2023-03-18 21:54:57 -04:00
a54cf8b87d use strict context boundaries and remove all n+1 queries 2023-03-18 21:06:50 -04:00
0b7146ba32 fix shot record error message 2023-03-18 01:05:09 -04:00
c0441957b6 use .link helpers 2023-03-18 01:03:55 -04:00
7fa9933a9b use core components 2023-03-18 00:25:18 -04:00
f4c7f22460 fix error message in ga locale 2023-03-17 21:10:34 -04:00
a01d97e360 use better domain for gettexts 2023-03-17 21:00:24 -04:00
a53b352cf7 Translated using Weblate (Irish)
Currently translated at 100.0% (14 of 14 strings)

Translation: cannery/emails
Translate-URL: https://weblate.bubbletea.dev/projects/cannery/emails/ga/
2023-03-18 00:52:59 +00:00
ce07cc2569 use live navigation to update state 2023-03-16 17:53:49 -04:00
3acecb9a93 remove extra @impl true 2023-03-16 17:41:25 -04:00
ab8561fcf0 use component macros for live_helper components 2023-03-15 00:48:07 -04:00
8163b906a2 remove data-qa 2023-03-15 00:47:15 -04:00
b29a5cce7b bump version 2023-03-10 16:58:49 -05:00
9205a04ac5 tweak invite and settings pages 2023-02-26 18:21:14 -05:00
632a9e1379 remove extraneous aliases 2023-02-26 14:22:54 -05:00
92cc49630d merge base project into cannery 2023-02-26 00:40:55 -05:00
a778f5a61f rename to cannery 2023-02-25 16:02:08 -05:00
07ff796553 fix more gettexts 2023-02-25 00:59:05 -05:00
07b31fcc86 remove doctests 2023-02-25 00:59:05 -05:00
bc034c0361 remove doctests 2023-02-25 00:58:33 -05:00
eb9280fa7e fix missing gettexts 2023-02-25 00:39:02 -05:00
ad1e44fd42 improve user settings 2023-02-14 01:06:50 -05:00
4e9f66f006 bump version 2023-02-14 01:06:00 -05:00
81350f9898 improve user settings page 2023-02-14 00:06:43 -05:00
9c4a32896f improve logger 2023-02-07 00:18:16 -05:00
56dae6cdfe improve logger 2023-02-07 00:14:20 -05:00
8ef3bd65a3 remove search from topbar 2023-02-07 00:06:25 -05:00
c8cadd6246 remove phoenix logo 2023-02-07 00:03:10 -05:00
3f143262d4 improve ci 2023-02-05 10:37:05 -05:00
ebe09bcf84 improve ci 2023-02-05 10:24:47 -05:00
fd0b2c455a fix tests 2023-02-04 21:50:43 -05:00
f1139d0ec4 improve oban logging 2023-02-04 20:47:47 -05:00
cd6bb6fbc3 fix padding on chrome 2023-02-04 20:40:46 -05:00
5c05f3b6fe use credo style 2023-02-04 17:32:40 -05:00
30d3f76fe1 record invites 2023-02-04 16:11:58 -05:00
ed8c20e967 improve invites 2023-02-04 15:05:00 -05:00
7b60938a75 improve templates 2023-02-04 13:01:11 -05:00
f19d024d8a fix bug with public registration being disabled 2023-02-04 11:35:32 -05:00
1fbed50b0f add crypto to extra applications 2023-02-01 22:39:38 -05:00
737484c36e add accounts doctests 2023-01-29 15:24:14 -05:00
6dbadc58ae fix runtime config 2023-01-26 19:55:59 -05:00
f155a43ee8 add qr code for invite link 2023-01-26 00:46:21 -05:00
bbaa1dfd6b update to liveview 0.18 2023-01-26 00:31:13 -05:00
2c2b9fefc9 use list for classes 2023-01-25 23:13:18 -05:00
bafc824a32 add subpixel antialiasing 2023-01-25 22:15:18 -05:00
8c2f7e0509 fix padding at bottom of page in chrome 2023-01-25 22:14:17 -05:00
22abc7a8d0 improve locale 2023-01-25 21:50:14 -05:00
5a685ac00e use topbar through npm 2023-01-25 21:18:00 -05:00
a5c12b3e17 fix webpack 2023-01-25 21:14:55 -05:00
27af5acf8b minimize disconnection modal 2023-01-25 21:00:34 -05:00
469428c007 add topbar.js on form submit and page navigate 2023-01-25 20:31:36 -05:00
1cd28e43b8 update elixir and npm versions 2023-01-22 23:20:02 -05:00
695002c9d9 don't override core aliases 2023-01-22 22:41:42 -05:00
dde60d71d1 update gettext 2023-01-22 22:37:49 -05:00
91794ddc55 upgrade dependencies 2023-01-22 22:36:20 -05:00
1e3cec95fe update drone config 2023-01-07 21:00:15 -05:00
f0a8c515f9 update deps 2023-01-07 21:00:15 -05:00
e99775eef2 fix gettext merge command 2023-01-07 21:00:15 -05:00
6760f83ca0 add gettext changes 2023-01-07 21:00:15 -05:00
10877bb754 rename page path to home path 2023-01-07 21:00:15 -05:00
38a581b639 add registered on date to user card 2023-01-07 21:00:15 -05:00
9408705430 update deps 2023-01-07 21:00:14 -05:00
302aa7eeda add locale to user settings 2023-01-07 21:00:14 -05:00
fd4fdcc36b add table component 2023-01-07 21:00:14 -05:00
3cb723b9e4 update build settings 2023-01-07 21:00:14 -05:00
1f92c452d1 fix tests 2023-01-07 21:00:14 -05:00
c10cff63ea add invites route 2023-01-07 21:00:14 -05:00
70faed71d0 style disconnection and loading screens 2023-01-07 21:00:14 -05:00
b5c46c09ec fix oban 2023-01-07 21:00:14 -05:00
7745765fc0 update npm 2023-01-07 21:00:14 -05:00
e16fbba810 gettext page_live 2023-01-07 21:00:14 -05:00
e35bdf101b update components 2023-01-07 21:00:14 -05:00
aa314e5ca1 update main routes 2023-01-07 21:00:14 -05:00
616de3c117 update tests 2023-01-07 21:00:14 -05:00
74bcec6cfe update controllers and auth 2023-01-07 21:00:14 -05:00
41090c46d0 add invites 2023-01-07 21:00:14 -05:00
c3f5744ad6 fix layout 2023-01-07 21:00:14 -05:00
95a339fe02 add random script 2023-01-07 21:00:14 -05:00
1e3b027367 fix docker files 2023-01-07 21:00:14 -05:00
dd46e1795f add credo 2023-01-07 21:00:14 -05:00
9e517e6477 add emails 2023-01-07 21:00:14 -05:00
34118299e9 add exdoc 2022-02-25 21:13:50 -05:00
97a9b6d51a prevent unconfirmed users from logging in 2022-02-16 22:16:19 -05:00
059004ba78 mix format 2022-02-16 22:15:47 -05:00
5d02ed6369 update deps 2022-02-16 22:15:16 -05:00
ec6acdbb5d update swoosh 2022-02-15 19:12:35 -05:00
33d82a902d add exdoc configs 2022-02-15 18:57:06 -05:00
fc5b03d680 enable live dashboard os monitoring 2022-02-15 18:57:01 -05:00
c918dbe4bf improve changeset errors 2022-02-15 18:22:58 -05:00
23b60e032d add datetime helper 2022-02-15 02:50:36 -05:00
7283932d85 use topbar component 2022-02-13 21:28:20 -05:00
8ff1fd0276 add gettext step to docker file 2022-02-13 15:50:48 -05:00
4ef09f5279 heex templates 2022-02-12 23:26:29 -05:00
9734be4966 add oban 2022-02-12 23:24:11 -05:00
485965d9c9 npm audit 2022-02-12 23:23:09 -05:00
e9cdb0f717 add dockerhub build and publish step 2022-02-12 02:38:29 -05:00
1c07449b54 add alpine.js 2022-02-12 02:37:10 -05:00
b64e85f65c add drone ci 2022-01-23 00:27:43 -05:00
9387756109 mix format 2022-01-23 00:00:53 -05:00
f1f3082368 add MaintainAttrs hook 2022-01-22 23:59:58 -05:00
50a8a79596 update readme and docker-compose.yml 2022-01-22 23:44:47 -05:00
67b48e1a3f format with standard js 2022-01-22 22:56:53 -05:00
67c30d7f88 add standard js 2022-01-22 22:56:38 -05:00
728728a5a4 fix tests 2022-01-22 20:44:38 -05:00
a64d92a6cf add dialyzer and credo to tests 2022-01-22 20:44:34 -05:00
6227d64072 set generator settings 2022-01-22 20:13:53 -05:00
46eed25a94 only start automigrator in prod env 2022-01-22 20:12:32 -05:00
3674eeaf5a fix tests 2022-01-22 15:24:04 -05:00
f0676a2433 add mix format to tests 2022-01-22 15:24:04 -05:00
a72a4b0cbe add heex_formatter 2022-01-22 15:24:04 -05:00
a2dea04668 update to 1.6 2022-01-22 14:25:37 -05:00
3dc255b7c2 add touchless docker deploys 2021-09-04 16:19:23 -04:00
bde1cff7a4 add font awesome 2021-09-02 23:32:53 -04:00
66cc11e9eb add liveview helpers 2021-09-02 23:32:53 -04:00
81a250206e update npm 2021-09-02 23:32:52 -04:00
6d5f7f68df style registration pages 2021-09-02 23:32:52 -04:00
5b5f1ce1e5 run phx.new and add phx.gen.auth 2021-08-15 19:11:09 -04:00
161 changed files with 15302 additions and 6344 deletions

View File

@ -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
View File

@ -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/,

View File

@ -1,3 +1,3 @@
elixir 1.14.1-otp-25
erlang 25.1.2
nodejs 18.12.1
nodejs 18.9.1

View File

@ -1,3 +1,33 @@
# 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

View File

@ -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`

View File

@ -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

View File

@ -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;
}

View File

@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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()) ::

View File

@ -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

View File

@ -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()

View File

@ -4,7 +4,8 @@ defmodule Cannery.ActivityLog do
"""
import Ecto.Query, warn: false
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo.AmmoGroup, Repo}
alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo}
alias Ecto.Multi
@doc """
@ -31,8 +32,10 @@ defmodule Cannery.ActivityLog do
Repo.all(
from sg in ShotGroup,
left_join: ag in assoc(sg, :ammo_group),
left_join: at in assoc(ag, :ammo_type),
left_join: ag in AmmoGroup,
on: sg.ammo_group_id == ag.id,
left_join: at in AmmoType,
on: ag.ammo_type_id == at.id,
where: sg.user_id == ^user_id,
where:
fragment(
@ -57,7 +60,20 @@ defmodule Cannery.ActivityLog do
sg.search,
^trimmed_search
)
}
},
distinct: sg.id
)
end
@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 +123,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 +242,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

View File

@ -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,42 +56,47 @@ 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_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_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_number(:count, greater_than: 0)
@ -103,25 +106,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

View File

@ -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)

View File

@ -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,
@ -64,14 +64,14 @@ defmodule Cannery.Ammo.AmmoType do
field :manufacturer, :string
field :upc, :string
belongs_to :user, User
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,
@ -95,12 +95,11 @@ 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())

View File

@ -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,
[]
)

View 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

View 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

View File

@ -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

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -37,9 +37,11 @@
<%= 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"
placeholder: gettext("Really great weather"),
phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %>
<%= error_tag(f, :notes, "col-span-3") %>

View File

@ -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

View File

@ -3,7 +3,8 @@ 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 Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -13,6 +14,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 +23,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,7 +48,8 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
ammo_type: ammo_type,
range: range,
container: container,
actions: actions
actions: actions,
show_used: show_used
}
} = socket
) do
@ -50,12 +57,12 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
if actions == [] do
[]
else
[%{label: nil, key: :actions, sortable: false}]
[%{label: gettext("Actions"), key: :actions, sortable: false}]
end
columns = [
%{label: gettext("Purchased on"), key: :purchased_on},
%{label: gettext("Last used on"), key: :used_up_on} | columns
%{label: gettext("Purchased on"), key: :purchased_on, type: ComparableDate},
%{label: gettext("Last used on"), key: :used_up_on, type: ComparableDate} | columns
]
columns =
@ -73,12 +80,24 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
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},
%{label: gettext("% left"), key: :remaining},
%{label: gettext("Notes"), key: :notes}
%{label: gettext("CPR"), key: :cpr}
| columns
]
columns =
if show_used do
[
%{label: gettext("Original Count"), key: :original_count},
%{label: gettext("% left"), key: :remaining}
| columns
]
else
columns
end
columns = [
%{label: if(show_used, do: gettext("Current Count"), else: gettext("Count")), key: :count}
| columns
]
@ -89,18 +108,27 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
[%{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)
@ -124,8 +152,6 @@ defmodule CanneryWeb.Components.AmmoGroupTableComponent do
@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 +173,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 +209,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 +230,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

View File

@ -3,7 +3,7 @@ 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 Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket}
@ -103,13 +103,13 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
[
%{
label: gettext("Used packs"),
key: :used_ammo_count,
type: :used_ammo_count
key: :used_pack_count,
type: :used_pack_count
},
%{
label: gettext("Total ever packs"),
key: :historical_ammo_count,
type: :historical_ammo_count
key: :historical_pack_count,
type: :historical_pack_count
}
]
else
@ -118,10 +118,36 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
)
|> Kernel.++([
%{label: gettext("Average CPR"), key: :avg_price_paid, type: :avg_price_paid},
%{label: nil, key: "actions", type: :actions, sortable: false}
%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}
])
extra_data = %{actions: actions, current_user: current_user}
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
[
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
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
@ -156,43 +182,69 @@ defmodule CanneryWeb.Components.AmmoTypeTableComponent do
defp get_ammo_type_value(:boolean, 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 +258,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

View File

@ -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

View File

@ -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>

View 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

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,3 @@
<time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date">
<%= Date.to_iso8601(@date, :extended) %>
</time>

View File

@ -0,0 +1,3 @@
<time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime">
<%= cast_datetime(@datetime) %>
</time>

View File

@ -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>

View File

@ -0,0 +1,42 @@
<.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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,30 @@
<label for={@id || @action} class="inline-flex relative 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"
>
<%= render_slot(@inner_block) %>
</span>
</label>

View 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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ->

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -3,41 +3,48 @@ 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)
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, true, current_user)
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)
@ -46,8 +53,8 @@ defmodule CanneryWeb.ExportController do
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!()

View File

@ -1,4 +1,8 @@
defmodule CanneryWeb.HomeController do
@moduledoc """
Controller for home page
"""
use CanneryWeb, :controller
def index(conn, _params) do

View File

@ -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

View File

@ -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,

View File

@ -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") %>

View File

@ -91,7 +91,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 +104,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

View File

@ -45,15 +45,16 @@
<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">
<.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="ammo_group_search"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search ammo")
) %>
@ -77,6 +78,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 +93,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 +106,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 +119,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 +140,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 +152,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 +167,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>
@ -161,8 +181,8 @@
<% 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 +194,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 +206,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 +218,5 @@
current_user={@current_user}
/>
</.modal>
<% true -> %>
<%= nil %>
<% _ -> %>
<% end %>

View File

@ -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>

View File

@ -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">

View File

@ -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 =

View File

@ -24,17 +24,19 @@
<%= 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"
<.link
href="https://shootersreference.com/reloadingdata/bullet_abbreviations/"
class="col-span-3 text-center link title text-md text-primary-600"
>
<%= gettext("Example bullet type abbreviations") %>
</a>
</.link>
<%= 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",
@ -52,14 +54,14 @@
<%= 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"
placeholder: gettext("5.56x46mm NATO")
) %>
<%= error_tag(f, :cartridge, "col-span-3 text-center") %>
<%= label(f, :caliber, gettext("Caliber"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :caliber,
class: "text-center col-span-2 input input-primary",
placeholder: ".223"
placeholder: gettext(".223")
) %>
<%= error_tag(f, :caliber, "col-span-3 text-center") %>
@ -112,21 +114,21 @@
<%= 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"
placeholder: gettext("+P")
) %>
<%= error_tag(f, :pressure, "col-span-3 text-center") %>
<%= 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"
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"
placeholder: gettext("Centerfire")
) %>
<%= error_tag(f, :firing_type, "col-span-3 text-center") %>

View File

@ -69,25 +69,21 @@ 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

View File

@ -20,15 +20,16 @@
<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">
<.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="ammo_type_search"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search catalog")
) %>
@ -60,7 +61,9 @@
<.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 +71,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 +81,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 +100,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>

View File

@ -4,8 +4,7 @@ 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 [
@ -31,17 +30,12 @@ defmodule CanneryWeb.AmmoTypeLive.Show do
]
@impl true
def mount(_params, _session, %{assigns: %{live_action: live_action}} = socket),
do: {:ok, socket |> assign(show_used: false, view_table: live_action == :table)}
def mount(_params, _session, socket),
do: {:ok, socket |> assign(show_used: false, view_table: true)}
@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,29 +52,18 @@ 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
@ -94,12 +77,54 @@ 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),
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_list: @fields_list,
fields_to_display: fields_to_display
)
@ -113,9 +138,6 @@ 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)
@spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
end

View File

@ -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>
@ -69,15 +71,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 +88,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 +114,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 +132,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 +170,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 +180,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 %>

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -27,9 +27,11 @@
<%= 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") %>
@ -42,9 +44,11 @@
<%= 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") %>

View File

@ -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

View File

@ -20,15 +20,16 @@
<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">
<.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"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search containers")
) %>
@ -61,6 +62,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 +74,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 +84,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>
@ -91,7 +99,9 @@
data-confirm={
dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name)
}
data-qa={"delete-#{container.id}"}
aria-label={
dgettext("actions", "Delete %{container_name}", container_name: container.name)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
@ -99,12 +109,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 +130,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 +140,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>
@ -134,7 +155,9 @@
data-confirm={
dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name)
}
data-qa={"delete-#{container.id}"}
aria-label={
dgettext("actions", "Delete %{container_name}", container_name: container.name)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
@ -144,10 +167,9 @@
<% 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 +180,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 %>

View File

@ -4,8 +4,7 @@ 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
@ -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,12 +82,10 @@ 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
@ -100,21 +96,29 @@ defmodule CanneryWeb.ContainerLive.Show do
id,
current_user
) do
%{name: container_name} =
container =
Containers.get_container!(id, current_user)
|> Repo.preload([:tags], force: true)
%{name: container_name} = container = Containers.get_container!(id, current_user)
ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used)
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: 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()

View File

@ -20,21 +20,18 @@
<%= unless @ammo_groups |> Enum.empty?() do %>
<span class="rounded-lg title text-lg">
<%= if @show_used do %>
<%= gettext("Total packs:") %>
<% else %>
<%= gettext("Packs:") %>
<% end %>
<%= @ammo_groups |> Enum.reject(fn %{count: count} -> count in [0, nil] end) |> Enum.count() %>
</span>
<span :if={@show_used} class="rounded-lg title text-lg">
<%= gettext("Total packs:") %>
<%= Enum.count(@ammo_groups) %>
</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 %>
@ -42,7 +39,7 @@
<.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 +51,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>
@ -119,6 +118,7 @@
id="ammo-type-show-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">
@ -128,17 +128,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 +155,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 +164,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 %>

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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="#"

View File

@ -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

View File

@ -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

View File

@ -29,8 +29,11 @@
<%= 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"
placeholder: gettext("Really great weather"),
phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %>
<%= error_tag(f, :notes, "col-span-3") %>

View File

@ -4,8 +4,7 @@ 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
@ -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
@ -107,16 +104,19 @@ defmodule CanneryWeb.RangeLive.Index do
@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)
shot_groups = ActivityLog.list_shot_groups(search, 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 +125,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)

View File

@ -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"
@ -70,15 +77,16 @@
<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">
<.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="shot_group_search"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search shot records")
) %>
@ -102,7 +110,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 +127,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 +142,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 +155,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 +167,5 @@
current_user={@current_user}
/>
</.modal>
<% _ -> %>
<% end %>

View File

@ -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)

View File

@ -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

View File

@ -23,15 +23,16 @@
<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">
<.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"
>
<%= text_input(f, :search_term,
class: "input input-primary",
value: @search,
role: "search",
phx_debounce: 300,
placeholder: gettext("Search tags")
) %>
@ -49,7 +50,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,7 +63,7 @@
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>

View File

@ -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

View File

@ -6,7 +6,7 @@
<br />
<span style="margin-bottom: 1em; font-size: 1.25em;">
<%= dgettext("emails", "Welcome to %{name}!", name: "Cannery") %>
<%= dgettext("emails", "Welcome to Cannery") %>
</span>
<br />
@ -15,11 +15,9 @@
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href="<%= @url %>"><%= @url %></a>
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<br />
<%= dgettext("emails",
"If you didn't create an account at %{name}, please ignore this.",
name: "Cannery") %>
<%= dgettext("emails", "If you didn't create an account at Cannery, please ignore this.") %>
</div>

View File

@ -1,7 +1,7 @@
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
<%= dgettext("emails", "Welcome to %{name}%!", name: "Cannery") %>
<%= dgettext("emails", "Welcome to Cannery") %>
<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %>

View File

@ -9,11 +9,9 @@
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href="<%= @url %>"><%= @url %></a>
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<br />
<%= dgettext("emails",
"If you didn't request this change from %{name}, please ignore this.",
name: "Cannery") %>
<%= dgettext("emails", "If you didn't request this change from Cannery, please ignore this.") %>
</div>

View File

@ -9,11 +9,12 @@
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href="<%= @url %>"><%= @url %></a>
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<br />
<%= dgettext("emails",
"If you didn't request this change from %{name}, please ignore this.",
name: "Cannery") %>
<%= dgettext(
"emails",
"If you didn't request this change from Cannery, please ignore this."
) %>
</div>

View File

@ -13,7 +13,7 @@
</head>
<body class="pb-8 m-0 p-0 w-full h-full">
<header>
<.topbar current_user={assigns[:current_user]}></.topbar>
<.topbar current_user={assigns[:current_user]} />
</header>
<div class="pb-8 w-full flex flex-col justify-center items-center text-center">
@ -24,9 +24,12 @@
<hr class="w-full hr" />
<a href={Routes.live_path(Endpoint, HomeLive)} class="link title text-primary-600 text-lg">
<.link
href={Routes.live_path(Endpoint, HomeLive)}
class="link title text-primary-600 text-lg"
>
<%= dgettext("errors", "Go back home") %>
</a>
</.link>
</div>
</div>
</body>

Some files were not shown because too many files have changed in this diff Show More