···11+# The directory Mix will write compiled artifacts to.
22+/_build/
33+44+# If you run "mix test --cover", coverage assets end up here.
55+/cover/
66+77+# The directory Mix downloads your dependencies sources to.
88+/deps/
99+1010+# Where 3rd-party dependencies like ExDoc output generated docs.
1111+/doc/
1212+1313+# Ignore .fetch files in case you like to edit your project deps locally.
1414+/.fetch
1515+1616+# If the VM crashes, it generates a dump, let's ignore it too.
1717+erl_crash.dump
1818+1919+# Also ignore archive artifacts (built via "mix archive.build").
2020+*.ez
2121+2222+# Temporary files, for example, from tests.
2323+/tmp/
2424+2525+# Ignore package tarball (built via "mix hex.build").
2626+sower-*.tar
2727+2828+# Ignore assets that are produced by build tools.
2929+/priv/static/assets/
3030+3131+# Ignore digested assets cache.
3232+/priv/static/cache_manifest.json
3333+3434+# In case you use Node.js/npm, you want to ignore these.
3535+npm-debug.log
3636+/assets/node_modules/
3737+
+18
README.md
···11+# Sower
22+33+To start your Phoenix server:
44+55+ * Run `mix setup` to install and setup dependencies
66+ * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
77+88+Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
99+1010+Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
1111+1212+## Learn more
1313+1414+ * Official website: https://www.phoenixframework.org/
1515+ * Guides: https://hexdocs.pm/phoenix/overview.html
1616+ * Docs: https://hexdocs.pm/phoenix
1717+ * Forum: https://elixirforum.com/c/phoenix-forum
1818+ * Source: https://github.com/phoenixframework/phoenix
+5
assets/css/app.css
···11+@import "tailwindcss/base";
22+@import "tailwindcss/components";
33+@import "tailwindcss/utilities";
44+55+/* This file is for your main application CSS */
+41
assets/js/app.js
···11+// If you want to use Phoenix channels, run `mix help phx.gen.channel`
22+// to get started and then uncomment the line below.
33+// import "./user_socket.js"
44+55+// You can include dependencies in two ways.
66+//
77+// The simplest option is to put them in assets/vendor and
88+// import them using relative paths:
99+//
1010+// import "../vendor/some-package.js"
1111+//
1212+// Alternatively, you can `npm install some-package --prefix assets` and import
1313+// them using a path starting with the package name:
1414+//
1515+// import "some-package"
1616+//
1717+1818+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
1919+import "phoenix_html"
2020+// Establish Phoenix Socket and LiveView configuration.
2121+import {Socket} from "phoenix"
2222+import {LiveSocket} from "phoenix_live_view"
2323+import topbar from "../vendor/topbar"
2424+2525+let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
2626+let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
2727+2828+// Show progress bar on live navigation and form submits
2929+topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
3030+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
3131+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
3232+3333+// connect if there are any LiveViews on the page
3434+liveSocket.connect()
3535+3636+// expose liveSocket on window for web console debug logs and latency simulation:
3737+// >> liveSocket.enableDebug()
3838+// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
3939+// >> liveSocket.disableLatencySim()
4040+window.liveSocket = liveSocket
4141+
···11+MIT License
22+33+Copyright (c) 2020 Refactoring UI Inc.
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+6
assets/vendor/heroicons/UPGRADE.md
···11+You are running heroicons v2.0.16. To upgrade in place, you can run the following command,
22+where your `HERO_VSN` export is your desired version:
33+44+ export HERO_VSN="2.0.16" ; \
55+ curl -L "https://github.com/tailwindlabs/heroicons/archive/refs/tags/v${HERO_VSN}.tar.gz" | \
66+ tar -xvz --strip-components=1 heroicons-${HERO_VSN}/optimized
···11+# This file is responsible for configuring your application
22+# and its dependencies with the aid of the Config module.
33+#
44+# This configuration file is loaded before any dependency and
55+# is restricted to this project.
66+77+# General application configuration
88+import Config
99+1010+config :sower,
1111+ ecto_repos: [Sower.Repo]
1212+1313+# Configures the endpoint
1414+config :sower, SowerWeb.Endpoint,
1515+ url: [host: "localhost"],
1616+ render_errors: [
1717+ formats: [html: SowerWeb.ErrorHTML, json: SowerWeb.ErrorJSON],
1818+ layout: false
1919+ ],
2020+ pubsub_server: Sower.PubSub,
2121+ live_view: [signing_salt: "nrwHFIM7"]
2222+2323+# Configures the mailer
2424+#
2525+# By default it uses the "Local" adapter which stores the emails
2626+# locally. You can see the emails in your browser, at "/dev/mailbox".
2727+#
2828+# For production it's recommended to configure a different adapter
2929+# at the `config/runtime.exs`.
3030+config :sower, Sower.Mailer, adapter: Swoosh.Adapters.Local
3131+3232+# Configure esbuild (the version is required)
3333+config :esbuild,
3434+ version: "0.17.11",
3535+ default: [
3636+ args:
3737+ ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
3838+ cd: Path.expand("../assets", __DIR__),
3939+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
4040+ ]
4141+4242+# Configure tailwind (the version is required)
4343+config :tailwind,
4444+ version: "3.3.2",
4545+ default: [
4646+ args: ~w(
4747+ --config=tailwind.config.js
4848+ --input=css/app.css
4949+ --output=../priv/static/assets/app.css
5050+ ),
5151+ cd: Path.expand("../assets", __DIR__)
5252+ ]
5353+5454+# Configures Elixir's Logger
5555+config :logger, :console,
5656+ format: "$time $metadata[$level] $message\n",
5757+ metadata: [:request_id]
5858+5959+# Use Jason for JSON parsing in Phoenix
6060+config :phoenix, :json_library, Jason
6161+6262+# Import environment specific config. This must remain at the bottom
6363+# of this file so it overrides the configuration defined above.
6464+import_config "#{config_env()}.exs"
+79
config/dev.exs
···11+import Config
22+33+# Configure your database
44+config :sower, Sower.Repo,
55+ username: "postgres",
66+ password: "postgres",
77+ hostname: "localhost",
88+ database: "sower_dev",
99+ stacktrace: true,
1010+ show_sensitive_data_on_connection_error: true,
1111+ pool_size: 10
1212+1313+# For development, we disable any cache and enable
1414+# debugging and code reloading.
1515+#
1616+# The watchers configuration can be used to run external
1717+# watchers to your application. For example, we can use it
1818+# to bundle .js and .css sources.
1919+config :sower, SowerWeb.Endpoint,
2020+ # Binding to loopback ipv4 address prevents access from other machines.
2121+ # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
2222+ http: [ip: {127, 0, 0, 1}, port: 4000],
2323+ check_origin: false,
2424+ code_reloader: true,
2525+ debug_errors: true,
2626+ secret_key_base: "AOSlLb08t2FYrLoZFv034r6d+8aI/sTv2RX028p5lP6jVm7npZFt4qDakFGDuZdl",
2727+ watchers: [
2828+ esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
2929+ tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
3030+ ]
3131+3232+# ## SSL Support
3333+#
3434+# In order to use HTTPS in development, a self-signed
3535+# certificate can be generated by running the following
3636+# Mix task:
3737+#
3838+# mix phx.gen.cert
3939+#
4040+# Run `mix help phx.gen.cert` for more information.
4141+#
4242+# The `http:` config above can be replaced with:
4343+#
4444+# https: [
4545+# port: 4001,
4646+# cipher_suite: :strong,
4747+# keyfile: "priv/cert/selfsigned_key.pem",
4848+# certfile: "priv/cert/selfsigned.pem"
4949+# ],
5050+#
5151+# If desired, both `http:` and `https:` keys can be
5252+# configured to run both http and https servers on
5353+# different ports.
5454+5555+# Watch static and templates for browser reloading.
5656+config :sower, SowerWeb.Endpoint,
5757+ live_reload: [
5858+ patterns: [
5959+ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
6060+ ~r"priv/gettext/.*(po)$",
6161+ ~r"lib/sower_web/(controllers|live|components)/.*(ex|heex)$"
6262+ ]
6363+ ]
6464+6565+# Enable dev routes for dashboard and mailbox
6666+config :sower, dev_routes: true
6767+6868+# Do not include metadata nor timestamps in development logs
6969+config :logger, :console, format: "[$level] $message\n"
7070+7171+# Set a higher stacktrace during development. Avoid configuring such
7272+# in production as building large stacktraces may be expensive.
7373+config :phoenix, :stacktrace_depth, 20
7474+7575+# Initialize plugs at runtime for faster development compilation
7676+config :phoenix, :plug_init_mode, :runtime
7777+7878+# Disable swoosh api client as it is only required for production adapters.
7979+config :swoosh, :api_client, false
+20
config/prod.exs
···11+import Config
22+33+# Note we also include the path to a cache manifest
44+# containing the digested version of static files. This
55+# manifest is generated by the `mix assets.deploy` task,
66+# which you should run after static files are built and
77+# before starting your production server.
88+config :sower, SowerWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
99+1010+# Configures Swoosh API Client
1111+config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Sower.Finch
1212+1313+# Disable Swoosh Local Memory Storage
1414+config :swoosh, local: false
1515+1616+# Do not print debug messages in production
1717+config :logger, level: :info
1818+1919+# Runtime production configuration, including reading
2020+# of environment variables, is done on config/runtime.exs.
+115
config/runtime.exs
···11+import Config
22+33+# config/runtime.exs is executed for all environments, including
44+# during releases. It is executed after compilation and before the
55+# system starts, so it is typically used to load production configuration
66+# and secrets from environment variables or elsewhere. Do not define
77+# any compile-time configuration in here, as it won't be applied.
88+# The block below contains prod specific runtime configuration.
99+1010+# ## Using releases
1111+#
1212+# If you use `mix release`, you need to explicitly enable the server
1313+# by passing the PHX_SERVER=true when you start it:
1414+#
1515+# PHX_SERVER=true bin/sower start
1616+#
1717+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
1818+# script that automatically sets the env var above.
1919+if System.get_env("PHX_SERVER") do
2020+ config :sower, SowerWeb.Endpoint, server: true
2121+end
2222+2323+if config_env() == :prod do
2424+ database_url =
2525+ System.get_env("DATABASE_URL") ||
2626+ raise """
2727+ environment variable DATABASE_URL is missing.
2828+ For example: ecto://USER:PASS@HOST/DATABASE
2929+ """
3030+3131+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
3232+3333+ config :sower, Sower.Repo,
3434+ # ssl: true,
3535+ url: database_url,
3636+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
3737+ socket_options: maybe_ipv6
3838+3939+ # The secret key base is used to sign/encrypt cookies and other secrets.
4040+ # A default value is used in config/dev.exs and config/test.exs but you
4141+ # want to use a different value for prod and you most likely don't want
4242+ # to check this value into version control, so we use an environment
4343+ # variable instead.
4444+ secret_key_base =
4545+ System.get_env("SECRET_KEY_BASE") ||
4646+ raise """
4747+ environment variable SECRET_KEY_BASE is missing.
4848+ You can generate one by calling: mix phx.gen.secret
4949+ """
5050+5151+ host = System.get_env("PHX_HOST") || "example.com"
5252+ port = String.to_integer(System.get_env("PORT") || "4000")
5353+5454+ config :sower, SowerWeb.Endpoint,
5555+ url: [host: host, port: 443, scheme: "https"],
5656+ http: [
5757+ # Enable IPv6 and bind on all interfaces.
5858+ # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
5959+ # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
6060+ # for details about using IPv6 vs IPv4 and loopback vs public addresses.
6161+ ip: {0, 0, 0, 0, 0, 0, 0, 0},
6262+ port: port
6363+ ],
6464+ secret_key_base: secret_key_base
6565+6666+ # ## SSL Support
6767+ #
6868+ # To get SSL working, you will need to add the `https` key
6969+ # to your endpoint configuration:
7070+ #
7171+ # config :sower, SowerWeb.Endpoint,
7272+ # https: [
7373+ # ...,
7474+ # port: 443,
7575+ # cipher_suite: :strong,
7676+ # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
7777+ # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
7878+ # ]
7979+ #
8080+ # The `cipher_suite` is set to `:strong` to support only the
8181+ # latest and more secure SSL ciphers. This means old browsers
8282+ # and clients may not be supported. You can set it to
8383+ # `:compatible` for wider support.
8484+ #
8585+ # `:keyfile` and `:certfile` expect an absolute path to the key
8686+ # and cert in disk or a relative path inside priv, for example
8787+ # "priv/ssl/server.key". For all supported SSL configuration
8888+ # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
8989+ #
9090+ # We also recommend setting `force_ssl` in your endpoint, ensuring
9191+ # no data is ever sent via http, always redirecting to https:
9292+ #
9393+ # config :sower, SowerWeb.Endpoint,
9494+ # force_ssl: [hsts: true]
9595+ #
9696+ # Check `Plug.SSL` for all available options in `force_ssl`.
9797+9898+ # ## Configuring the mailer
9999+ #
100100+ # In production you need to configure the mailer to use a different adapter.
101101+ # Also, you may need to configure the Swoosh API client of your choice if you
102102+ # are not using SMTP. Here is an example of the configuration:
103103+ #
104104+ # config :sower, Sower.Mailer,
105105+ # adapter: Swoosh.Adapters.Mailgun,
106106+ # api_key: System.get_env("MAILGUN_API_KEY"),
107107+ # domain: System.get_env("MAILGUN_DOMAIN")
108108+ #
109109+ # For this example you need include a HTTP client required by Swoosh API client.
110110+ # Swoosh supports Hackney and Finch out of the box:
111111+ #
112112+ # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
113113+ #
114114+ # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
115115+end
+33
config/test.exs
···11+import Config
22+33+# Configure your database
44+#
55+# The MIX_TEST_PARTITION environment variable can be used
66+# to provide built-in test partitioning in CI environment.
77+# Run `mix help test` for more information.
88+config :sower, Sower.Repo,
99+ username: "postgres",
1010+ password: "postgres",
1111+ hostname: "localhost",
1212+ database: "sower_test#{System.get_env("MIX_TEST_PARTITION")}",
1313+ pool: Ecto.Adapters.SQL.Sandbox,
1414+ pool_size: 10
1515+1616+# We don't run a server during test. If one is required,
1717+# you can enable the server option below.
1818+config :sower, SowerWeb.Endpoint,
1919+ http: [ip: {127, 0, 0, 1}, port: 4002],
2020+ secret_key_base: "+qA3jXKscezV25y28C5SmMaQum/CvKrh0+1obODeDAlsBR8V94RaTB0rp8lDVhB9",
2121+ server: false
2222+2323+# In test we don't send emails.
2424+config :sower, Sower.Mailer, adapter: Swoosh.Adapters.Test
2525+2626+# Disable swoosh api client as it is only required for production adapters.
2727+config :swoosh, :api_client, false
2828+2929+# Print only warnings and errors during test
3030+config :logger, level: :warning
3131+3232+# Initialize plugs at runtime for faster test compilation
3333+config :phoenix, :plug_init_mode, :runtime
···11+defmodule Sower do
22+ @moduledoc """
33+ Sower keeps the contexts that define your domain
44+ and business logic.
55+66+ Contexts are also responsible for managing your data, regardless
77+ if it comes from the database, an external API or others.
88+ """
99+end
+38
lib/sower/application.ex
···11+defmodule Sower.Application do
22+ # See https://hexdocs.pm/elixir/Application.html
33+ # for more information on OTP Applications
44+ @moduledoc false
55+66+ use Application
77+88+ @impl true
99+ def start(_type, _args) do
1010+ children = [
1111+ # Start the Telemetry supervisor
1212+ SowerWeb.Telemetry,
1313+ # Start the Ecto repository
1414+ Sower.Repo,
1515+ # Start the PubSub system
1616+ {Phoenix.PubSub, name: Sower.PubSub},
1717+ # Start Finch
1818+ {Finch, name: Sower.Finch},
1919+ # Start the Endpoint (http/https)
2020+ SowerWeb.Endpoint
2121+ # Start a worker by calling: Sower.Worker.start_link(arg)
2222+ # {Sower.Worker, arg}
2323+ ]
2424+2525+ # See https://hexdocs.pm/elixir/Supervisor.html
2626+ # for other strategies and supported options
2727+ opts = [strategy: :one_for_one, name: Sower.Supervisor]
2828+ Supervisor.start_link(children, opts)
2929+ end
3030+3131+ # Tell Phoenix to update the endpoint configuration
3232+ # whenever the application is updated.
3333+ @impl true
3434+ def config_change(changed, _new, removed) do
3535+ SowerWeb.Endpoint.config_change(changed, removed)
3636+ :ok
3737+ end
3838+end
+3
lib/sower/mailer.ex
···11+defmodule Sower.Mailer do
22+ use Swoosh.Mailer, otp_app: :sower
33+end
+5
lib/sower/repo.ex
···11+defmodule Sower.Repo do
22+ use Ecto.Repo,
33+ otp_app: :sower,
44+ adapter: Ecto.Adapters.Postgres
55+end
+113
lib/sower_web.ex
···11+defmodule SowerWeb do
22+ @moduledoc """
33+ The entrypoint for defining your web interface, such
44+ as controllers, components, channels, and so on.
55+66+ This can be used in your application as:
77+88+ use SowerWeb, :controller
99+ use SowerWeb, :html
1010+1111+ The definitions below will be executed for every controller,
1212+ component, etc, so keep them short and clean, focused
1313+ on imports, uses and aliases.
1414+1515+ Do NOT define functions inside the quoted expressions
1616+ below. Instead, define additional modules and import
1717+ those modules here.
1818+ """
1919+2020+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
2121+2222+ def router do
2323+ quote do
2424+ use Phoenix.Router, helpers: false
2525+2626+ # Import common connection and controller functions to use in pipelines
2727+ import Plug.Conn
2828+ import Phoenix.Controller
2929+ import Phoenix.LiveView.Router
3030+ end
3131+ end
3232+3333+ def channel do
3434+ quote do
3535+ use Phoenix.Channel
3636+ end
3737+ end
3838+3939+ def controller do
4040+ quote do
4141+ use Phoenix.Controller,
4242+ formats: [:html, :json],
4343+ layouts: [html: SowerWeb.Layouts]
4444+4545+ import Plug.Conn
4646+ import SowerWeb.Gettext
4747+4848+ unquote(verified_routes())
4949+ end
5050+ end
5151+5252+ def live_view do
5353+ quote do
5454+ use Phoenix.LiveView,
5555+ layout: {SowerWeb.Layouts, :app}
5656+5757+ unquote(html_helpers())
5858+ end
5959+ end
6060+6161+ def live_component do
6262+ quote do
6363+ use Phoenix.LiveComponent
6464+6565+ unquote(html_helpers())
6666+ end
6767+ end
6868+6969+ def html do
7070+ quote do
7171+ use Phoenix.Component
7272+7373+ # Import convenience functions from controllers
7474+ import Phoenix.Controller,
7575+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
7676+7777+ # Include general helpers for rendering HTML
7878+ unquote(html_helpers())
7979+ end
8080+ end
8181+8282+ defp html_helpers do
8383+ quote do
8484+ # HTML escaping functionality
8585+ import Phoenix.HTML
8686+ # Core UI components and translation
8787+ import SowerWeb.CoreComponents
8888+ import SowerWeb.Gettext
8989+9090+ # Shortcut for generating JS commands
9191+ alias Phoenix.LiveView.JS
9292+9393+ # Routes generation with the ~p sigil
9494+ unquote(verified_routes())
9595+ end
9696+ end
9797+9898+ def verified_routes do
9999+ quote do
100100+ use Phoenix.VerifiedRoutes,
101101+ endpoint: SowerWeb.Endpoint,
102102+ router: SowerWeb.Router,
103103+ statics: SowerWeb.static_paths()
104104+ end
105105+ end
106106+107107+ @doc """
108108+ When used, dispatch to the appropriate controller/view/etc.
109109+ """
110110+ defmacro __using__(which) when is_atom(which) do
111111+ apply(__MODULE__, which, [])
112112+ end
113113+end
+665
lib/sower_web/components/core_components.ex
···11+defmodule SowerWeb.CoreComponents do
22+ @moduledoc """
33+ Provides core UI components.
44+55+ At the first glance, this module may seem daunting, but its goal is
66+ to provide some core building blocks in your application, such as modals,
77+ tables, and forms. The components are mostly markup and well documented
88+ with doc strings and declarative assigns. You may customize and style
99+ them in any way you want, based on your application growth and needs.
1010+1111+ The default components use Tailwind CSS, a utility-first CSS framework.
1212+ See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
1313+ how to customize them or feel free to swap in another framework altogether.
1414+1515+ Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
1616+ """
1717+ use Phoenix.Component
1818+1919+ alias Phoenix.LiveView.JS
2020+ import SowerWeb.Gettext
2121+2222+ @doc """
2323+ Renders a modal.
2424+2525+ ## Examples
2626+2727+ <.modal id="confirm-modal">
2828+ This is a modal.
2929+ </.modal>
3030+3131+ JS commands may be passed to the `:on_cancel` to configure
3232+ the closing/cancel event, for example:
3333+3434+ <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
3535+ This is another modal.
3636+ </.modal>
3737+3838+ """
3939+ attr :id, :string, required: true
4040+ attr :show, :boolean, default: false
4141+ attr :on_cancel, JS, default: %JS{}
4242+ slot :inner_block, required: true
4343+4444+ def modal(assigns) do
4545+ ~H"""
4646+ <div
4747+ id={@id}
4848+ phx-mounted={@show && show_modal(@id)}
4949+ phx-remove={hide_modal(@id)}
5050+ data-cancel={JS.exec(@on_cancel, "phx-remove")}
5151+ class="relative z-50 hidden"
5252+ >
5353+ <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
5454+ <div
5555+ class="fixed inset-0 overflow-y-auto"
5656+ aria-labelledby={"#{@id}-title"}
5757+ aria-describedby={"#{@id}-description"}
5858+ role="dialog"
5959+ aria-modal="true"
6060+ tabindex="0"
6161+ >
6262+ <div class="flex min-h-full items-center justify-center">
6363+ <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
6464+ <.focus_wrap
6565+ id={"#{@id}-container"}
6666+ phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
6767+ phx-key="escape"
6868+ phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
6969+ class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
7070+ >
7171+ <div class="absolute top-6 right-5">
7272+ <button
7373+ phx-click={JS.exec("data-cancel", to: "##{@id}")}
7474+ type="button"
7575+ class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
7676+ aria-label={gettext("close")}
7777+ >
7878+ <.icon name="hero-x-mark-solid" class="h-5 w-5" />
7979+ </button>
8080+ </div>
8181+ <div id={"#{@id}-content"}>
8282+ <%= render_slot(@inner_block) %>
8383+ </div>
8484+ </.focus_wrap>
8585+ </div>
8686+ </div>
8787+ </div>
8888+ </div>
8989+ """
9090+ end
9191+9292+ @doc """
9393+ Renders flash notices.
9494+9595+ ## Examples
9696+9797+ <.flash kind={:info} flash={@flash} />
9898+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
9999+ """
100100+ attr :id, :string, default: "flash", doc: "the optional id of flash container"
101101+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
102102+ attr :title, :string, default: nil
103103+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
104104+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
105105+106106+ slot :inner_block, doc: "the optional inner block that renders the flash message"
107107+108108+ def flash(assigns) do
109109+ ~H"""
110110+ <div
111111+ :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
112112+ id={@id}
113113+ phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
114114+ role="alert"
115115+ class={[
116116+ "fixed top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
117117+ @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
118118+ @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
119119+ ]}
120120+ {@rest}
121121+ >
122122+ <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
123123+ <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
124124+ <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
125125+ <%= @title %>
126126+ </p>
127127+ <p class="mt-2 text-sm leading-5"><%= msg %></p>
128128+ <button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
129129+ <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
130130+ </button>
131131+ </div>
132132+ """
133133+ end
134134+135135+ @doc """
136136+ Shows the flash group with standard titles and content.
137137+138138+ ## Examples
139139+140140+ <.flash_group flash={@flash} />
141141+ """
142142+ attr :flash, :map, required: true, doc: "the map of flash messages"
143143+144144+ def flash_group(assigns) do
145145+ ~H"""
146146+ <.flash kind={:info} title="Success!" flash={@flash} />
147147+ <.flash kind={:error} title="Error!" flash={@flash} />
148148+ <.flash
149149+ id="client-error"
150150+ kind={:error}
151151+ title="We can't find the internet"
152152+ phx-disconnected={show(".phx-client-error #client-error")}
153153+ phx-connected={hide("#client-error")}
154154+ hidden
155155+ >
156156+ Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
157157+ </.flash>
158158+159159+ <.flash
160160+ id="server-error"
161161+ kind={:error}
162162+ title="Something went wrong!"
163163+ phx-disconnected={show(".phx-server-error #server-error")}
164164+ phx-connected={hide("#server-error")}
165165+ hidden
166166+ >
167167+ Hang in there while we get back on track
168168+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
169169+ </.flash>
170170+ """
171171+ end
172172+173173+ @doc """
174174+ Renders a simple form.
175175+176176+ ## Examples
177177+178178+ <.simple_form for={@form} phx-change="validate" phx-submit="save">
179179+ <.input field={@form[:email]} label="Email"/>
180180+ <.input field={@form[:username]} label="Username" />
181181+ <:actions>
182182+ <.button>Save</.button>
183183+ </:actions>
184184+ </.simple_form>
185185+ """
186186+ attr :for, :any, required: true, doc: "the datastructure for the form"
187187+ attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
188188+189189+ attr :rest, :global,
190190+ include: ~w(autocomplete name rel action enctype method novalidate target multipart),
191191+ doc: "the arbitrary HTML attributes to apply to the form tag"
192192+193193+ slot :inner_block, required: true
194194+ slot :actions, doc: "the slot for form actions, such as a submit button"
195195+196196+ def simple_form(assigns) do
197197+ ~H"""
198198+ <.form :let={f} for={@for} as={@as} {@rest}>
199199+ <div class="mt-10 space-y-8 bg-white">
200200+ <%= render_slot(@inner_block, f) %>
201201+ <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
202202+ <%= render_slot(action, f) %>
203203+ </div>
204204+ </div>
205205+ </.form>
206206+ """
207207+ end
208208+209209+ @doc """
210210+ Renders a button.
211211+212212+ ## Examples
213213+214214+ <.button>Send!</.button>
215215+ <.button phx-click="go" class="ml-2">Send!</.button>
216216+ """
217217+ attr :type, :string, default: nil
218218+ attr :class, :string, default: nil
219219+ attr :rest, :global, include: ~w(disabled form name value)
220220+221221+ slot :inner_block, required: true
222222+223223+ def button(assigns) do
224224+ ~H"""
225225+ <button
226226+ type={@type}
227227+ class={[
228228+ "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
229229+ "text-sm font-semibold leading-6 text-white active:text-white/80",
230230+ @class
231231+ ]}
232232+ {@rest}
233233+ >
234234+ <%= render_slot(@inner_block) %>
235235+ </button>
236236+ """
237237+ end
238238+239239+ @doc """
240240+ Renders an input with label and error messages.
241241+242242+ A `Phoenix.HTML.FormField` may be passed as argument,
243243+ which is used to retrieve the input name, id, and values.
244244+ Otherwise all attributes may be passed explicitly.
245245+246246+ ## Types
247247+248248+ This function accepts all HTML input types, considering that:
249249+250250+ * You may also set `type="select"` to render a `<select>` tag
251251+252252+ * `type="checkbox"` is used exclusively to render boolean values
253253+254254+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
255255+256256+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
257257+ for more information.
258258+259259+ ## Examples
260260+261261+ <.input field={@form[:email]} type="email" />
262262+ <.input name="my-input" errors={["oh no!"]} />
263263+ """
264264+ attr :id, :any, default: nil
265265+ attr :name, :any
266266+ attr :label, :string, default: nil
267267+ attr :value, :any
268268+269269+ attr :type, :string,
270270+ default: "text",
271271+ values: ~w(checkbox color date datetime-local email file hidden month number password
272272+ range radio search select tel text textarea time url week)
273273+274274+ attr :field, Phoenix.HTML.FormField,
275275+ doc: "a form field struct retrieved from the form, for example: @form[:email]"
276276+277277+ attr :errors, :list, default: []
278278+ attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
279279+ attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
280280+ attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
281281+ attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
282282+283283+ attr :rest, :global,
284284+ include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
285285+ multiple pattern placeholder readonly required rows size step)
286286+287287+ slot :inner_block
288288+289289+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
290290+ assigns
291291+ |> assign(field: nil, id: assigns.id || field.id)
292292+ |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
293293+ |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
294294+ |> assign_new(:value, fn -> field.value end)
295295+ |> input()
296296+ end
297297+298298+ def input(%{type: "checkbox", value: value} = assigns) do
299299+ assigns =
300300+ assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)
301301+302302+ ~H"""
303303+ <div phx-feedback-for={@name}>
304304+ <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
305305+ <input type="hidden" name={@name} value="false" />
306306+ <input
307307+ type="checkbox"
308308+ id={@id}
309309+ name={@name}
310310+ value="true"
311311+ checked={@checked}
312312+ class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
313313+ {@rest}
314314+ />
315315+ <%= @label %>
316316+ </label>
317317+ <.error :for={msg <- @errors}><%= msg %></.error>
318318+ </div>
319319+ """
320320+ end
321321+322322+ def input(%{type: "select"} = assigns) do
323323+ ~H"""
324324+ <div phx-feedback-for={@name}>
325325+ <.label for={@id}><%= @label %></.label>
326326+ <select
327327+ id={@id}
328328+ name={@name}
329329+ class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
330330+ multiple={@multiple}
331331+ {@rest}
332332+ >
333333+ <option :if={@prompt} value=""><%= @prompt %></option>
334334+ <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
335335+ </select>
336336+ <.error :for={msg <- @errors}><%= msg %></.error>
337337+ </div>
338338+ """
339339+ end
340340+341341+ def input(%{type: "textarea"} = assigns) do
342342+ ~H"""
343343+ <div phx-feedback-for={@name}>
344344+ <.label for={@id}><%= @label %></.label>
345345+ <textarea
346346+ id={@id}
347347+ name={@name}
348348+ class={[
349349+ "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
350350+ "min-h-[6rem] phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
351351+ @errors == [] && "border-zinc-300 focus:border-zinc-400",
352352+ @errors != [] && "border-rose-400 focus:border-rose-400"
353353+ ]}
354354+ {@rest}
355355+ ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
356356+ <.error :for={msg <- @errors}><%= msg %></.error>
357357+ </div>
358358+ """
359359+ end
360360+361361+ # All other inputs text, datetime-local, url, password, etc. are handled here...
362362+ def input(assigns) do
363363+ ~H"""
364364+ <div phx-feedback-for={@name}>
365365+ <.label for={@id}><%= @label %></.label>
366366+ <input
367367+ type={@type}
368368+ name={@name}
369369+ id={@id}
370370+ value={Phoenix.HTML.Form.normalize_value(@type, @value)}
371371+ class={[
372372+ "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
373373+ "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
374374+ @errors == [] && "border-zinc-300 focus:border-zinc-400",
375375+ @errors != [] && "border-rose-400 focus:border-rose-400"
376376+ ]}
377377+ {@rest}
378378+ />
379379+ <.error :for={msg <- @errors}><%= msg %></.error>
380380+ </div>
381381+ """
382382+ end
383383+384384+ @doc """
385385+ Renders a label.
386386+ """
387387+ attr :for, :string, default: nil
388388+ slot :inner_block, required: true
389389+390390+ def label(assigns) do
391391+ ~H"""
392392+ <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
393393+ <%= render_slot(@inner_block) %>
394394+ </label>
395395+ """
396396+ end
397397+398398+ @doc """
399399+ Generates a generic error message.
400400+ """
401401+ slot :inner_block, required: true
402402+403403+ def error(assigns) do
404404+ ~H"""
405405+ <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden">
406406+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
407407+ <%= render_slot(@inner_block) %>
408408+ </p>
409409+ """
410410+ end
411411+412412+ @doc """
413413+ Renders a header with title.
414414+ """
415415+ attr :class, :string, default: nil
416416+417417+ slot :inner_block, required: true
418418+ slot :subtitle
419419+ slot :actions
420420+421421+ def header(assigns) do
422422+ ~H"""
423423+ <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
424424+ <div>
425425+ <h1 class="text-lg font-semibold leading-8 text-zinc-800">
426426+ <%= render_slot(@inner_block) %>
427427+ </h1>
428428+ <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
429429+ <%= render_slot(@subtitle) %>
430430+ </p>
431431+ </div>
432432+ <div class="flex-none"><%= render_slot(@actions) %></div>
433433+ </header>
434434+ """
435435+ end
436436+437437+ @doc ~S"""
438438+ Renders a table with generic styling.
439439+440440+ ## Examples
441441+442442+ <.table id="users" rows={@users}>
443443+ <:col :let={user} label="id"><%= user.id %></:col>
444444+ <:col :let={user} label="username"><%= user.username %></:col>
445445+ </.table>
446446+ """
447447+ attr :id, :string, required: true
448448+ attr :rows, :list, required: true
449449+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
450450+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
451451+452452+ attr :row_item, :any,
453453+ default: &Function.identity/1,
454454+ doc: "the function for mapping each row before calling the :col and :action slots"
455455+456456+ slot :col, required: true do
457457+ attr :label, :string
458458+ end
459459+460460+ slot :action, doc: "the slot for showing user actions in the last table column"
461461+462462+ def table(assigns) do
463463+ assigns =
464464+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
465465+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
466466+ end
467467+468468+ ~H"""
469469+ <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
470470+ <table class="w-[40rem] mt-11 sm:w-full">
471471+ <thead class="text-sm text-left leading-6 text-zinc-500">
472472+ <tr>
473473+ <th :for={col <- @col} class="p-0 pr-6 pb-4 font-normal"><%= col[:label] %></th>
474474+ <th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th>
475475+ </tr>
476476+ </thead>
477477+ <tbody
478478+ id={@id}
479479+ phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
480480+ class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
481481+ >
482482+ <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
483483+ <td
484484+ :for={{col, i} <- Enum.with_index(@col)}
485485+ phx-click={@row_click && @row_click.(row)}
486486+ class={["relative p-0", @row_click && "hover:cursor-pointer"]}
487487+ >
488488+ <div class="block py-4 pr-6">
489489+ <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
490490+ <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
491491+ <%= render_slot(col, @row_item.(row)) %>
492492+ </span>
493493+ </div>
494494+ </td>
495495+ <td :if={@action != []} class="relative w-14 p-0">
496496+ <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
497497+ <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
498498+ <span
499499+ :for={action <- @action}
500500+ class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
501501+ >
502502+ <%= render_slot(action, @row_item.(row)) %>
503503+ </span>
504504+ </div>
505505+ </td>
506506+ </tr>
507507+ </tbody>
508508+ </table>
509509+ </div>
510510+ """
511511+ end
512512+513513+ @doc """
514514+ Renders a data list.
515515+516516+ ## Examples
517517+518518+ <.list>
519519+ <:item title="Title"><%= @post.title %></:item>
520520+ <:item title="Views"><%= @post.views %></:item>
521521+ </.list>
522522+ """
523523+ slot :item, required: true do
524524+ attr :title, :string, required: true
525525+ end
526526+527527+ def list(assigns) do
528528+ ~H"""
529529+ <div class="mt-14">
530530+ <dl class="-my-4 divide-y divide-zinc-100">
531531+ <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
532532+ <dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
533533+ <dd class="text-zinc-700"><%= render_slot(item) %></dd>
534534+ </div>
535535+ </dl>
536536+ </div>
537537+ """
538538+ end
539539+540540+ @doc """
541541+ Renders a back navigation link.
542542+543543+ ## Examples
544544+545545+ <.back navigate={~p"/posts"}>Back to posts</.back>
546546+ """
547547+ attr :navigate, :any, required: true
548548+ slot :inner_block, required: true
549549+550550+ def back(assigns) do
551551+ ~H"""
552552+ <div class="mt-16">
553553+ <.link
554554+ navigate={@navigate}
555555+ class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
556556+ >
557557+ <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
558558+ <%= render_slot(@inner_block) %>
559559+ </.link>
560560+ </div>
561561+ """
562562+ end
563563+564564+ @doc """
565565+ Renders a [Heroicon](https://heroicons.com).
566566+567567+ Heroicons come in three styles – outline, solid, and mini.
568568+ By default, the outline style is used, but solid and mini may
569569+ be applied by using the `-solid` and `-mini` suffix.
570570+571571+ You can customize the size and colors of the icons by setting
572572+ width, height, and background color classes.
573573+574574+ Icons are extracted from your `assets/vendor/heroicons` directory and bundled
575575+ within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
576576+577577+ ## Examples
578578+579579+ <.icon name="hero-x-mark-solid" />
580580+ <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
581581+ """
582582+ attr :name, :string, required: true
583583+ attr :class, :string, default: nil
584584+585585+ def icon(%{name: "hero-" <> _} = assigns) do
586586+ ~H"""
587587+ <span class={[@name, @class]} />
588588+ """
589589+ end
590590+591591+ ## JS Commands
592592+593593+ def show(js \\ %JS{}, selector) do
594594+ JS.show(js,
595595+ to: selector,
596596+ transition:
597597+ {"transition-all transform ease-out duration-300",
598598+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
599599+ "opacity-100 translate-y-0 sm:scale-100"}
600600+ )
601601+ end
602602+603603+ def hide(js \\ %JS{}, selector) do
604604+ JS.hide(js,
605605+ to: selector,
606606+ time: 200,
607607+ transition:
608608+ {"transition-all transform ease-in duration-200",
609609+ "opacity-100 translate-y-0 sm:scale-100",
610610+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
611611+ )
612612+ end
613613+614614+ def show_modal(js \\ %JS{}, id) when is_binary(id) do
615615+ js
616616+ |> JS.show(to: "##{id}")
617617+ |> JS.show(
618618+ to: "##{id}-bg",
619619+ transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
620620+ )
621621+ |> show("##{id}-container")
622622+ |> JS.add_class("overflow-hidden", to: "body")
623623+ |> JS.focus_first(to: "##{id}-content")
624624+ end
625625+626626+ def hide_modal(js \\ %JS{}, id) do
627627+ js
628628+ |> JS.hide(
629629+ to: "##{id}-bg",
630630+ transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
631631+ )
632632+ |> hide("##{id}-container")
633633+ |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
634634+ |> JS.remove_class("overflow-hidden", to: "body")
635635+ |> JS.pop_focus()
636636+ end
637637+638638+ @doc """
639639+ Translates an error message using gettext.
640640+ """
641641+ def translate_error({msg, opts}) do
642642+ # When using gettext, we typically pass the strings we want
643643+ # to translate as a static argument:
644644+ #
645645+ # # Translate the number of files with plural rules
646646+ # dngettext("errors", "1 file", "%{count} files", count)
647647+ #
648648+ # However the error messages in our forms and APIs are generated
649649+ # dynamically, so we need to translate them by calling Gettext
650650+ # with our gettext backend as first argument. Translations are
651651+ # available in the errors.po file (as we use the "errors" domain).
652652+ if count = opts[:count] do
653653+ Gettext.dngettext(SowerWeb.Gettext, "errors", msg, msg, count, opts)
654654+ else
655655+ Gettext.dgettext(SowerWeb.Gettext, "errors", msg, opts)
656656+ end
657657+ end
658658+659659+ @doc """
660660+ Translates the errors for a field from a keyword list of errors.
661661+ """
662662+ def translate_errors(errors, field) when is_list(errors) do
663663+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
664664+ end
665665+end
+5
lib/sower_web/components/layouts.ex
···11+defmodule SowerWeb.Layouts do
22+ use SowerWeb, :html
33+44+ embed_templates "layouts/*"
55+end
···11+defmodule SowerWeb.ErrorHTML do
22+ use SowerWeb, :html
33+44+ # If you want to customize your error pages,
55+ # uncomment the embed_templates/1 call below
66+ # and add pages to the error directory:
77+ #
88+ # * lib/sower_web/controllers/error_html/404.html.heex
99+ # * lib/sower_web/controllers/error_html/500.html.heex
1010+ #
1111+ # embed_templates "error_html/*"
1212+1313+ # The default is to render a plain text page based on
1414+ # the template name. For example, "404.html" becomes
1515+ # "Not Found".
1616+ def render(template, _assigns) do
1717+ Phoenix.Controller.status_message_from_template(template)
1818+ end
1919+end
+15
lib/sower_web/controllers/error_json.ex
···11+defmodule SowerWeb.ErrorJSON do
22+ # If you want to customize a particular status code,
33+ # you may add your own clauses, such as:
44+ #
55+ # def render("500.json", _assigns) do
66+ # %{errors: %{detail: "Internal Server Error"}}
77+ # end
88+99+ # By default, Phoenix returns the status message from
1010+ # the template name. For example, "404.json" becomes
1111+ # "Not Found".
1212+ def render(template, _assigns) do
1313+ %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
1414+ end
1515+end
+9
lib/sower_web/controllers/page_controller.ex
···11+defmodule SowerWeb.PageController do
22+ use SowerWeb, :controller
33+44+ def home(conn, _params) do
55+ # The home page is often custom made,
66+ # so skip the default app layout.
77+ render(conn, :home, layout: false)
88+ end
99+end
+5
lib/sower_web/controllers/page_html.ex
···11+defmodule SowerWeb.PageHTML do
22+ use SowerWeb, :html
33+44+ embed_templates "page_html/*"
55+end
···11+defmodule SowerWeb.Endpoint do
22+ use Phoenix.Endpoint, otp_app: :sower
33+44+ # The session will be stored in the cookie and signed,
55+ # this means its contents can be read but not tampered with.
66+ # Set :encryption_salt if you would also like to encrypt it.
77+ @session_options [
88+ store: :cookie,
99+ key: "_sower_key",
1010+ signing_salt: "HgaquzJR",
1111+ same_site: "Lax"
1212+ ]
1313+1414+ socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
1515+1616+ # Serve at "/" the static files from "priv/static" directory.
1717+ #
1818+ # You should set gzip to true if you are running phx.digest
1919+ # when deploying your static files in production.
2020+ plug Plug.Static,
2121+ at: "/",
2222+ from: :sower,
2323+ gzip: false,
2424+ only: SowerWeb.static_paths()
2525+2626+ # Code reloading can be explicitly enabled under the
2727+ # :code_reloader configuration of your endpoint.
2828+ if code_reloading? do
2929+ socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
3030+ plug Phoenix.LiveReloader
3131+ plug Phoenix.CodeReloader
3232+ plug Phoenix.Ecto.CheckRepoStatus, otp_app: :sower
3333+ end
3434+3535+ plug Phoenix.LiveDashboard.RequestLogger,
3636+ param_key: "request_logger",
3737+ cookie_key: "request_logger"
3838+3939+ plug Plug.RequestId
4040+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
4141+4242+ plug Plug.Parsers,
4343+ parsers: [:urlencoded, :multipart, :json],
4444+ pass: ["*/*"],
4545+ json_decoder: Phoenix.json_library()
4646+4747+ plug Plug.MethodOverride
4848+ plug Plug.Head
4949+ plug Plug.Session, @session_options
5050+ plug SowerWeb.Router
5151+end
+24
lib/sower_web/gettext.ex
···11+defmodule SowerWeb.Gettext do
22+ @moduledoc """
33+ A module providing Internationalization with a gettext-based API.
44+55+ By using [Gettext](https://hexdocs.pm/gettext),
66+ your module gains a set of macros for translations, for example:
77+88+ import SowerWeb.Gettext
99+1010+ # Simple translation
1111+ gettext("Here is the string to translate")
1212+1313+ # Plural translation
1414+ ngettext("Here is the string to translate",
1515+ "Here are the strings to translate",
1616+ 3)
1717+1818+ # Domain-based translation
1919+ dgettext("errors", "Here is the error message to translate")
2020+2121+ See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
2222+ """
2323+ use Gettext, otp_app: :sower
2424+end
+44
lib/sower_web/router.ex
···11+defmodule SowerWeb.Router do
22+ use SowerWeb, :router
33+44+ pipeline :browser do
55+ plug :accepts, ["html"]
66+ plug :fetch_session
77+ plug :fetch_live_flash
88+ plug :put_root_layout, html: {SowerWeb.Layouts, :root}
99+ plug :protect_from_forgery
1010+ plug :put_secure_browser_headers
1111+ end
1212+1313+ pipeline :api do
1414+ plug :accepts, ["json"]
1515+ end
1616+1717+ scope "/", SowerWeb do
1818+ pipe_through :browser
1919+2020+ get "/", PageController, :home
2121+ end
2222+2323+ # Other scopes may use custom stacks.
2424+ # scope "/api", SowerWeb do
2525+ # pipe_through :api
2626+ # end
2727+2828+ # Enable LiveDashboard and Swoosh mailbox preview in development
2929+ if Application.compile_env(:sower, :dev_routes) do
3030+ # If you want to use the LiveDashboard in production, you should put
3131+ # it behind authentication and allow only admins to access it.
3232+ # If your application does not have an admins-only section yet,
3333+ # you can use Plug.BasicAuth to set up some basic authentication
3434+ # as long as you are also using SSL (which you should anyway).
3535+ import Phoenix.LiveDashboard.Router
3636+3737+ scope "/dev" do
3838+ pipe_through :browser
3939+4040+ live_dashboard "/dashboard", metrics: SowerWeb.Telemetry
4141+ forward "/mailbox", Plug.Swoosh.MailboxPreview
4242+ end
4343+ end
4444+end
+92
lib/sower_web/telemetry.ex
···11+defmodule SowerWeb.Telemetry do
22+ use Supervisor
33+ import Telemetry.Metrics
44+55+ def start_link(arg) do
66+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
77+ end
88+99+ @impl true
1010+ def init(_arg) do
1111+ children = [
1212+ # Telemetry poller will execute the given period measurements
1313+ # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
1414+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
1515+ # Add reporters as children of your supervision tree.
1616+ # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
1717+ ]
1818+1919+ Supervisor.init(children, strategy: :one_for_one)
2020+ end
2121+2222+ def metrics do
2323+ [
2424+ # Phoenix Metrics
2525+ summary("phoenix.endpoint.start.system_time",
2626+ unit: {:native, :millisecond}
2727+ ),
2828+ summary("phoenix.endpoint.stop.duration",
2929+ unit: {:native, :millisecond}
3030+ ),
3131+ summary("phoenix.router_dispatch.start.system_time",
3232+ tags: [:route],
3333+ unit: {:native, :millisecond}
3434+ ),
3535+ summary("phoenix.router_dispatch.exception.duration",
3636+ tags: [:route],
3737+ unit: {:native, :millisecond}
3838+ ),
3939+ summary("phoenix.router_dispatch.stop.duration",
4040+ tags: [:route],
4141+ unit: {:native, :millisecond}
4242+ ),
4343+ summary("phoenix.socket_connected.duration",
4444+ unit: {:native, :millisecond}
4545+ ),
4646+ summary("phoenix.channel_joined.duration",
4747+ unit: {:native, :millisecond}
4848+ ),
4949+ summary("phoenix.channel_handled_in.duration",
5050+ tags: [:event],
5151+ unit: {:native, :millisecond}
5252+ ),
5353+5454+ # Database Metrics
5555+ summary("sower.repo.query.total_time",
5656+ unit: {:native, :millisecond},
5757+ description: "The sum of the other measurements"
5858+ ),
5959+ summary("sower.repo.query.decode_time",
6060+ unit: {:native, :millisecond},
6161+ description: "The time spent decoding the data received from the database"
6262+ ),
6363+ summary("sower.repo.query.query_time",
6464+ unit: {:native, :millisecond},
6565+ description: "The time spent executing the query"
6666+ ),
6767+ summary("sower.repo.query.queue_time",
6868+ unit: {:native, :millisecond},
6969+ description: "The time spent waiting for a database connection"
7070+ ),
7171+ summary("sower.repo.query.idle_time",
7272+ unit: {:native, :millisecond},
7373+ description:
7474+ "The time the connection spent waiting before being checked out for the query"
7575+ ),
7676+7777+ # VM Metrics
7878+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
7979+ summary("vm.total_run_queue_lengths.total"),
8080+ summary("vm.total_run_queue_lengths.cpu"),
8181+ summary("vm.total_run_queue_lengths.io")
8282+ ]
8383+ end
8484+8585+ defp periodic_measurements do
8686+ [
8787+ # A module, function and arguments to be invoked periodically.
8888+ # This function must call :telemetry.execute/3 and a metric must be added above.
8989+ # {SowerWeb, :count_users, []}
9090+ ]
9191+ end
9292+end
+73
mix.exs
···11+defmodule Sower.MixProject do
22+ use Mix.Project
33+44+ def project do
55+ [
66+ app: :sower,
77+ version: "0.1.0",
88+ elixir: "~> 1.14",
99+ elixirc_paths: elixirc_paths(Mix.env()),
1010+ start_permanent: Mix.env() == :prod,
1111+ aliases: aliases(),
1212+ deps: deps()
1313+ ]
1414+ end
1515+1616+ # Configuration for the OTP application.
1717+ #
1818+ # Type `mix help compile.app` for more information.
1919+ def application do
2020+ [
2121+ mod: {Sower.Application, []},
2222+ extra_applications: [:logger, :runtime_tools]
2323+ ]
2424+ end
2525+2626+ # Specifies which paths to compile per environment.
2727+ defp elixirc_paths(:test), do: ["lib", "test/support"]
2828+ defp elixirc_paths(_), do: ["lib"]
2929+3030+ # Specifies your project dependencies.
3131+ #
3232+ # Type `mix help deps` for examples and options.
3333+ defp deps do
3434+ [
3535+ {:phoenix, "~> 1.7.7"},
3636+ {:phoenix_ecto, "~> 4.4"},
3737+ {:ecto_sql, "~> 3.10"},
3838+ {:postgrex, ">= 0.0.0"},
3939+ {:phoenix_html, "~> 3.3"},
4040+ {:phoenix_live_reload, "~> 1.2", only: :dev},
4141+ {:phoenix_live_view, "~> 0.19.0"},
4242+ {:floki, ">= 0.30.0", only: :test},
4343+ {:phoenix_live_dashboard, "~> 0.8.0"},
4444+ {:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
4545+ {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
4646+ {:swoosh, "~> 1.3"},
4747+ {:finch, "~> 0.13"},
4848+ {:telemetry_metrics, "~> 0.6"},
4949+ {:telemetry_poller, "~> 1.0"},
5050+ {:gettext, "~> 0.20"},
5151+ {:jason, "~> 1.2"},
5252+ {:plug_cowboy, "~> 2.5"}
5353+ ]
5454+ end
5555+5656+ # Aliases are shortcuts or tasks specific to the current project.
5757+ # For example, to install project dependencies and perform other setup tasks, run:
5858+ #
5959+ # $ mix setup
6060+ #
6161+ # See the documentation for `Mix` for more info on aliases.
6262+ defp aliases do
6363+ [
6464+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
6565+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
6666+ "ecto.reset": ["ecto.drop", "ecto.setup"],
6767+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
6868+ "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
6969+ "assets.build": ["tailwind default", "esbuild default"],
7070+ "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
7171+ ]
7272+ end
7373+end
···11+## `msgid`s in this file come from POT (.pot) files.
22+##
33+## Do not add, change, or remove `msgid`s manually here as
44+## they're tied to the ones in the corresponding POT file
55+## (with the same domain).
66+##
77+## Use `mix gettext.extract --merge` or `mix gettext.merge`
88+## to merge POT files into PO files.
99+msgid ""
1010+msgstr ""
1111+"Language: en\n"
1212+1313+## From Ecto.Changeset.cast/4
1414+msgid "can't be blank"
1515+msgstr ""
1616+1717+## From Ecto.Changeset.unique_constraint/3
1818+msgid "has already been taken"
1919+msgstr ""
2020+2121+## From Ecto.Changeset.put_change/3
2222+msgid "is invalid"
2323+msgstr ""
2424+2525+## From Ecto.Changeset.validate_acceptance/3
2626+msgid "must be accepted"
2727+msgstr ""
2828+2929+## From Ecto.Changeset.validate_format/3
3030+msgid "has invalid format"
3131+msgstr ""
3232+3333+## From Ecto.Changeset.validate_subset/3
3434+msgid "has an invalid entry"
3535+msgstr ""
3636+3737+## From Ecto.Changeset.validate_exclusion/3
3838+msgid "is reserved"
3939+msgstr ""
4040+4141+## From Ecto.Changeset.validate_confirmation/3
4242+msgid "does not match confirmation"
4343+msgstr ""
4444+4545+## From Ecto.Changeset.no_assoc_constraint/3
4646+msgid "is still associated with this entry"
4747+msgstr ""
4848+4949+msgid "are still associated with this entry"
5050+msgstr ""
5151+5252+## From Ecto.Changeset.validate_length/3
5353+msgid "should have %{count} item(s)"
5454+msgid_plural "should have %{count} item(s)"
5555+msgstr[0] ""
5656+msgstr[1] ""
5757+5858+msgid "should be %{count} character(s)"
5959+msgid_plural "should be %{count} character(s)"
6060+msgstr[0] ""
6161+msgstr[1] ""
6262+6363+msgid "should be %{count} byte(s)"
6464+msgid_plural "should be %{count} byte(s)"
6565+msgstr[0] ""
6666+msgstr[1] ""
6767+6868+msgid "should have at least %{count} item(s)"
6969+msgid_plural "should have at least %{count} item(s)"
7070+msgstr[0] ""
7171+msgstr[1] ""
7272+7373+msgid "should be at least %{count} character(s)"
7474+msgid_plural "should be at least %{count} character(s)"
7575+msgstr[0] ""
7676+msgstr[1] ""
7777+7878+msgid "should be at least %{count} byte(s)"
7979+msgid_plural "should be at least %{count} byte(s)"
8080+msgstr[0] ""
8181+msgstr[1] ""
8282+8383+msgid "should have at most %{count} item(s)"
8484+msgid_plural "should have at most %{count} item(s)"
8585+msgstr[0] ""
8686+msgstr[1] ""
8787+8888+msgid "should be at most %{count} character(s)"
8989+msgid_plural "should be at most %{count} character(s)"
9090+msgstr[0] ""
9191+msgstr[1] ""
9292+9393+msgid "should be at most %{count} byte(s)"
9494+msgid_plural "should be at most %{count} byte(s)"
9595+msgstr[0] ""
9696+msgstr[1] ""
9797+9898+## From Ecto.Changeset.validate_number/3
9999+msgid "must be less than %{number}"
100100+msgstr ""
101101+102102+msgid "must be greater than %{number}"
103103+msgstr ""
104104+105105+msgid "must be less than or equal to %{number}"
106106+msgstr ""
107107+108108+msgid "must be greater than or equal to %{number}"
109109+msgstr ""
110110+111111+msgid "must be equal to %{number}"
112112+msgstr ""
+110
priv/gettext/errors.pot
···11+## This is a PO Template file.
22+##
33+## `msgid`s here are often extracted from source code.
44+## Add new translations manually only if they're dynamic
55+## translations that can't be statically extracted.
66+##
77+## Run `mix gettext.extract` to bring this file up to
88+## date. Leave `msgstr`s empty as changing them here has no
99+## effect: edit them in PO (`.po`) files instead.
1010+1111+## From Ecto.Changeset.cast/4
1212+msgid "can't be blank"
1313+msgstr ""
1414+1515+## From Ecto.Changeset.unique_constraint/3
1616+msgid "has already been taken"
1717+msgstr ""
1818+1919+## From Ecto.Changeset.put_change/3
2020+msgid "is invalid"
2121+msgstr ""
2222+2323+## From Ecto.Changeset.validate_acceptance/3
2424+msgid "must be accepted"
2525+msgstr ""
2626+2727+## From Ecto.Changeset.validate_format/3
2828+msgid "has invalid format"
2929+msgstr ""
3030+3131+## From Ecto.Changeset.validate_subset/3
3232+msgid "has an invalid entry"
3333+msgstr ""
3434+3535+## From Ecto.Changeset.validate_exclusion/3
3636+msgid "is reserved"
3737+msgstr ""
3838+3939+## From Ecto.Changeset.validate_confirmation/3
4040+msgid "does not match confirmation"
4141+msgstr ""
4242+4343+## From Ecto.Changeset.no_assoc_constraint/3
4444+msgid "is still associated with this entry"
4545+msgstr ""
4646+4747+msgid "are still associated with this entry"
4848+msgstr ""
4949+5050+## From Ecto.Changeset.validate_length/3
5151+msgid "should have %{count} item(s)"
5252+msgid_plural "should have %{count} item(s)"
5353+msgstr[0] ""
5454+msgstr[1] ""
5555+5656+msgid "should be %{count} character(s)"
5757+msgid_plural "should be %{count} character(s)"
5858+msgstr[0] ""
5959+msgstr[1] ""
6060+6161+msgid "should be %{count} byte(s)"
6262+msgid_plural "should be %{count} byte(s)"
6363+msgstr[0] ""
6464+msgstr[1] ""
6565+6666+msgid "should have at least %{count} item(s)"
6767+msgid_plural "should have at least %{count} item(s)"
6868+msgstr[0] ""
6969+msgstr[1] ""
7070+7171+msgid "should be at least %{count} character(s)"
7272+msgid_plural "should be at least %{count} character(s)"
7373+msgstr[0] ""
7474+msgstr[1] ""
7575+7676+msgid "should be at least %{count} byte(s)"
7777+msgid_plural "should be at least %{count} byte(s)"
7878+msgstr[0] ""
7979+msgstr[1] ""
8080+8181+msgid "should have at most %{count} item(s)"
8282+msgid_plural "should have at most %{count} item(s)"
8383+msgstr[0] ""
8484+msgstr[1] ""
8585+8686+msgid "should be at most %{count} character(s)"
8787+msgid_plural "should be at most %{count} character(s)"
8888+msgstr[0] ""
8989+msgstr[1] ""
9090+9191+msgid "should be at most %{count} byte(s)"
9292+msgid_plural "should be at most %{count} byte(s)"
9393+msgstr[0] ""
9494+msgstr[1] ""
9595+9696+## From Ecto.Changeset.validate_number/3
9797+msgid "must be less than %{number}"
9898+msgstr ""
9999+100100+msgid "must be greater than %{number}"
101101+msgstr ""
102102+103103+msgid "must be less than or equal to %{number}"
104104+msgstr ""
105105+106106+msgid "must be greater than or equal to %{number}"
107107+msgstr ""
108108+109109+msgid "must be equal to %{number}"
110110+msgstr ""
···11+# Script for populating the database. You can run it as:
22+#
33+# mix run priv/repo/seeds.exs
44+#
55+# Inside the script, you can read and write to any of your
66+# repositories directly:
77+#
88+# Sower.Repo.insert!(%Sower.SomeSchema{})
99+#
1010+# We recommend using the bang functions (`insert!`, `update!`
1111+# and so on) as they will fail if something goes wrong.
···11+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
22+#
33+# To ban all spiders from the entire site uncomment the next two lines:
44+# User-agent: *
55+# Disallow: /
+14
test/sower_web/controllers/error_html_test.exs
···11+defmodule SowerWeb.ErrorHTMLTest do
22+ use SowerWeb.ConnCase, async: true
33+44+ # Bring render_to_string/4 for testing custom views
55+ import Phoenix.Template
66+77+ test "renders 404.html" do
88+ assert render_to_string(SowerWeb.ErrorHTML, "404", "html", []) == "Not Found"
99+ end
1010+1111+ test "renders 500.html" do
1212+ assert render_to_string(SowerWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
1313+ end
1414+end
+12
test/sower_web/controllers/error_json_test.exs
···11+defmodule SowerWeb.ErrorJSONTest do
22+ use SowerWeb.ConnCase, async: true
33+44+ test "renders 404" do
55+ assert SowerWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
66+ end
77+88+ test "renders 500" do
99+ assert SowerWeb.ErrorJSON.render("500.json", %{}) ==
1010+ %{errors: %{detail: "Internal Server Error"}}
1111+ end
1212+end
···11+defmodule SowerWeb.PageControllerTest do
22+ use SowerWeb.ConnCase
33+44+ test "GET /", %{conn: conn} do
55+ conn = get(conn, ~p"/")
66+ assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
77+ end
88+end
+38
test/support/conn_case.ex
···11+defmodule SowerWeb.ConnCase do
22+ @moduledoc """
33+ This module defines the test case to be used by
44+ tests that require setting up a connection.
55+66+ Such tests rely on `Phoenix.ConnTest` and also
77+ import other functionality to make it easier
88+ to build common data structures and query the data layer.
99+1010+ Finally, if the test case interacts with the database,
1111+ we enable the SQL sandbox, so changes done to the database
1212+ are reverted at the end of every test. If you are using
1313+ PostgreSQL, you can even run database tests asynchronously
1414+ by setting `use SowerWeb.ConnCase, async: true`, although
1515+ this option is not recommended for other databases.
1616+ """
1717+1818+ use ExUnit.CaseTemplate
1919+2020+ using do
2121+ quote do
2222+ # The default endpoint for testing
2323+ @endpoint SowerWeb.Endpoint
2424+2525+ use SowerWeb, :verified_routes
2626+2727+ # Import conveniences for testing with connections
2828+ import Plug.Conn
2929+ import Phoenix.ConnTest
3030+ import SowerWeb.ConnCase
3131+ end
3232+ end
3333+3434+ setup tags do
3535+ Sower.DataCase.setup_sandbox(tags)
3636+ {:ok, conn: Phoenix.ConnTest.build_conn()}
3737+ end
3838+end
+58
test/support/data_case.ex
···11+defmodule Sower.DataCase do
22+ @moduledoc """
33+ This module defines the setup for tests requiring
44+ access to the application's data layer.
55+66+ You may define functions here to be used as helpers in
77+ your tests.
88+99+ Finally, if the test case interacts with the database,
1010+ we enable the SQL sandbox, so changes done to the database
1111+ are reverted at the end of every test. If you are using
1212+ PostgreSQL, you can even run database tests asynchronously
1313+ by setting `use Sower.DataCase, async: true`, although
1414+ this option is not recommended for other databases.
1515+ """
1616+1717+ use ExUnit.CaseTemplate
1818+1919+ using do
2020+ quote do
2121+ alias Sower.Repo
2222+2323+ import Ecto
2424+ import Ecto.Changeset
2525+ import Ecto.Query
2626+ import Sower.DataCase
2727+ end
2828+ end
2929+3030+ setup tags do
3131+ Sower.DataCase.setup_sandbox(tags)
3232+ :ok
3333+ end
3434+3535+ @doc """
3636+ Sets up the sandbox based on the test tags.
3737+ """
3838+ def setup_sandbox(tags) do
3939+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Sower.Repo, shared: not tags[:async])
4040+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
4141+ end
4242+4343+ @doc """
4444+ A helper that transforms changeset errors into a map of messages.
4545+4646+ assert {:error, changeset} = Accounts.create_user(%{password: "short"})
4747+ assert "password is too short" in errors_on(changeset).password
4848+ assert %{password: ["password is too short"]} = errors_on(changeset)
4949+5050+ """
5151+ def errors_on(changeset) do
5252+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
5353+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
5454+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
5555+ end)
5656+ end)
5757+ end
5858+end