diff --git a/.formatter.exs b/.formatter.exs index 197a6c9d..6bde019f 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,7 @@ [ - inputs: ["*.exs", "{config,lib,priv,rel,test}/**/*.{ex,exs}"], - line_length: 180, - plugins: [Styler] + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"], + line_length: 180 ] diff --git a/.gitignore b/.gitignore index 89a9ec10..cf5eda12 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ /db /deps /*.ez -/cover + +# Temporary files /tmp +/cover # Generated on crash by the VM erl_crash.dump diff --git a/Dockerfile b/Dockerfile index fc2d8834..ef453ca1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,9 @@ ENV MIX_ENV="prod" COPY mix.exs mix.lock ./ RUN mix deps.get --only $MIX_ENV +# Setup assets dependencies (Esbuild, Tailwind, etc…) so the are cached +RUN mix assets.setup + # Copy compile-time config files before we compile dependencies # to ensure any relevant config change will trigger the dependencies # to be re-compiled. diff --git a/assets/css/app.css b/assets/css/app.css index 989c539b..a31e4441 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,43 +1,3 @@ -.home { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 40px; - text-align: center; - font-family: - system-ui, - -apple-system, - 'Segoe UI', - Roboto, - 'Helvetica Neue', - Arial, - 'Noto Sans', - 'Liberation Sans', - sans-serif, - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - 'Noto Color Emoji'; - line-height: 1.4; -} - -.home a { - display: block; - margin: 0 0 20px; -} - -.home p { - margin: 0 0 20px; -} - -.home p:last-child { - margin-bottom: 0; -} - -.flash-messages { - position: fixed; - top: 0; - right: 0; - padding: 10px; -} +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; diff --git a/assets/js/app.js b/assets/js/app.js index 7a224a63..47bee88f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,38 +1,22 @@ -import 'simple-css-reset/reset.css'; -import '../css/app.css'; +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import 'phoenix_html'; +// Establish Phoenix Socket and LiveView configuration. import {Socket} from 'phoenix'; import {LiveSocket} from 'phoenix_live_view'; -const FLASH_TTL = 8000; -const Hooks = {}; - -Hooks.Flash = { - mounted() { - this.timer = setTimeout(() => this._hide(), FLASH_TTL); - - this.el.addEventListener('mouseover', () => { - clearTimeout(this.timer); - this.timer = setTimeout(() => this._hide(), FLASH_TTL); - }); - }, - - destroyed() { - clearTimeout(this.timer); - }, - - _hide() { - liveSocket.execJS(this.el, this.el.getAttribute('phx-click')); - } -}; - const csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute('content'); - const liveSocket = new LiveSocket('/live', Socket, { - hooks: Hooks, params: {_csrf_token: csrfToken} // eslint-disable-line camelcase }); +// connect if there are any LiveViews on the page liveSocket.connect(); + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket; diff --git a/assets/package-lock.json b/assets/package-lock.json index bf207c1d..6dadc0ef 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -7,12 +7,6 @@ "": { "name": "elixir-boilerplate", "version": "0.0.1", - "dependencies": { - "phoenix": "^1.7.7", - "phoenix_html": "^3.3.1", - "phoenix_live_view": "^0.20.5", - "simple-css-reset": "^3.0.0" - }, "devDependencies": { "@babel/eslint-parser": "^7.23.10", "eslint": "^8.56.0", @@ -29,11 +23,13 @@ } }, "../deps/phoenix": { - "version": "1.7.7", + "version": "1.7.11", + "extraneous": true, "license": "MIT" }, "../deps/phoenix_html": { - "version": "3.3.2" + "version": "3.3.3", + "extraneous": true }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", @@ -2432,19 +2428,6 @@ "node": ">=8" } }, - "node_modules/phoenix": { - "resolved": "../deps/phoenix", - "link": true - }, - "node_modules/phoenix_html": { - "resolved": "../deps/phoenix_html", - "link": true - }, - "node_modules/phoenix_live_view": { - "version": "0.20.5", - "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.20.5.tgz", - "integrity": "sha512-FgwuGVvanKLs8dZj7k2JBI2fBijcY5DJoFS7A1va4+PAF+R5lqLSwIhHQF1PTtcoV95Av9v9kmj/3yMgTiY9JQ==" - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -2771,11 +2754,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-css-reset": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/simple-css-reset/-/simple-css-reset-3.0.0.tgz", - "integrity": "sha512-IN1NRbrCL9pLVBFzzyXmJfkgJAS4b5VwcWFXdpEGMx9asEUZ7AbSnqbLHnB5CvCPa7+uu41aLAsguiUQQvrBmw==" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/assets/package.json b/assets/package.json index c9ef1acc..a2e44ce1 100644 --- a/assets/package.json +++ b/assets/package.json @@ -7,12 +7,6 @@ "node": "^20.5.0", "npm": "^9.8.0" }, - "dependencies": { - "phoenix": "^1.7.7", - "phoenix_html": "^3.3.1", - "phoenix_live_view": "^0.20.5", - "simple-css-reset": "^3.0.0" - }, "devDependencies": { "@babel/eslint-parser": "^7.23.10", "eslint": "^8.56.0", diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 00000000..148264b0 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,99 @@ +/* eslint-env node */ + +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require('tailwindcss/plugin'); +// const fs = require('fs'); +// const path = require('path'); + +module.exports = { + content: ['./js/**/*.js', '../lib/*_web.ex', '../lib/*_web/**/*.*ex'], + theme: { + extend: { + colors: { + brand: '#FD4F00' + } + } + }, + plugins: [ + require('@tailwindcss/forms'), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => + addVariant('phx-no-feedback', ['.phx-no-feedback&', '.phx-no-feedback &']) + ), + plugin(({addVariant}) => + addVariant('phx-click-loading', [ + '.phx-click-loading&', + '.phx-click-loading &' + ]) + ), + plugin(({addVariant}) => + addVariant('phx-submit-loading', [ + '.phx-submit-loading&', + '.phx-submit-loading &' + ]) + ), + plugin(({addVariant}) => + addVariant('phx-change-loading', [ + '.phx-change-loading&', + '.phx-change-loading &' + ]) + ), + plugin(({addVariant}) => + addVariant('phx-change-loading', [ + '.phx-change-loading&', + '.phx-change-loading &' + ]) + ) + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // plugin(({matchComponents, theme}) => { + // const iconsDir = path.join(__dirname, './vendor/heroicons/optimized'); + // const values = {}; + // const icons = [ + // ['', '/24/outline'], + // ['-solid', '/24/solid'], + // ['-mini', '/20/solid'] + // ]; + + // /* eslint max-nested-callbacks: ["error", 3] */ + // icons.forEach(([suffix, dir]) => { + // fs.readdirSync(path.join(iconsDir, dir)).map((file) => { + // const name = path.basename(file, '.svg') + suffix; + + // values[name] = {name, fullPath: path.join(iconsDir, dir, file)}; + // }); + // }); + + // matchComponents( + // { + // hero: ({name, fullPath}) => { + // const content = fs + // .readFileSync(fullPath) + // .toString() + // .replace(/\r?\n|\r/g, ''); + + // return { + // [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + // '-webkit-mask': `var(--hero-${name})`, + // mask: `var(--hero-${name})`, + // 'mask-repeat': 'no-repeat', + // 'background-color': 'currentColor', + // 'vertical-align': 'middle', + // display: 'inline-block', + // width: theme('spacing.5'), + // height: theme('spacing.5') + // }; + // } + // }, + // {values} + // ); + // }) + ] +}; diff --git a/config/config.exs b/config/config.exs index 8832a514..108d2233 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,7 +10,10 @@ config :phoenix, :json_library, Jason config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, pubsub_server: ElixirBoilerplate.PubSub, - render_errors: [view: ElixirBoilerplateWeb.Errors, accepts: ~w(html json)] + render_errors: [ + formats: [html: ElixirBoilerplateWeb.Controllers.ErrorHTML, json: ElixirBoilerplateWeb.Controllers.ErrorJSON], + layout: false + ] config :elixir_boilerplate, ElixirBoilerplate.Repo, migration_primary_key: [type: :binary_id, default: {:fragment, "gen_random_uuid()"}], @@ -30,13 +33,24 @@ config :absinthe_security, AbsintheSecurity.Phase.MaxDepthCheck, max_depth_count config :absinthe_security, AbsintheSecurity.Phase.MaxDirectivesCheck, max_directive_count: 100 config :esbuild, - version: "0.16.4", + version: "0.17.11", default: [ - args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets), + args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] +config :tailwind, + version: "3.2.7", + default: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + config :sentry, included_environments: [:all], root_source_code_path: File.cwd!(), diff --git a/config/dev.exs b/config/dev.exs index 10c51388..07282ca2 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -5,13 +5,14 @@ config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, debug_errors: true, check_origin: false, watchers: [ - esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} ], live_reload: [ patterns: [ - ~r{priv/gettext/.*$}, - ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, - ~r{lib/elixir_boilerplate_web/.*(ee?x)$} + ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/elixir_boilerplate_web/../.*(ex|heex)$" ] ] @@ -21,3 +22,6 @@ config :logger, :console, format: "[$level] $message\n" config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime + +# Enable dev routes for dashboard and mailbox +config :elixir_boilerplate, dev_routes: true diff --git a/config/prod.exs b/config/prod.exs index 65651460..28f645cf 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -8,3 +8,6 @@ config :logger, :console, format: "$time $metadata[$level] $message\n", level: :info, metadata: ~w(request_id graphql_operation_name)a + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs index a79f2cef..555b2284 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -20,11 +20,14 @@ if get_env("PHX_SERVER", :boolean) == true do config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, server: true end +config :elixir_boilerplate, ElixirBoilerplateWeb.Session, + session_key: get_env!("SESSION_KEY"), + session_signing_salt: get_env!("SESSION_SIGNING_SALT") + config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, http: [port: get_env!("PORT", :integer)], secret_key_base: get_env!("SECRET_KEY_BASE"), session_key: get_env!("SESSION_KEY"), - session_signing_salt: get_env!("SESSION_SIGNING_SALT"), live_view: [signing_salt: get_env!("SESSION_SIGNING_SALT")], url: get_endpoint_url_config(canonical_uri), static_url: get_endpoint_url_config(static_uri) diff --git a/lib/elixir_boilerplate_web/api/version/controller.ex b/lib/elixir_boilerplate_web/api/version/controller.ex new file mode 100644 index 00000000..b5d4e357 --- /dev/null +++ b/lib/elixir_boilerplate_web/api/version/controller.ex @@ -0,0 +1,8 @@ +defmodule ElixirBoilerplateWeb.Api.Version.Controller do + use ElixirBoilerplateWeb.Controller + + @spec index(Plug.Conn.t(), map) :: Plug.Conn.t() + def index(conn, _) do + json(conn, %{version: Application.get_env(:elixir_boilerplate, :version)}) + end +end diff --git a/lib/elixir_boilerplate_web/component.ex b/lib/elixir_boilerplate_web/component.ex new file mode 100644 index 00000000..342c81c0 --- /dev/null +++ b/lib/elixir_boilerplate_web/component.ex @@ -0,0 +1,9 @@ +defmodule ElixirBoilerplateWeb.Component do + defmacro __using__(_opts) do + quote do + use Phoenix.LiveComponent + + unquote(ElixirBoilerplateWeb.html_helpers()) + end + end +end diff --git a/lib/elixir_boilerplate_web/components/branding.ex b/lib/elixir_boilerplate_web/components/branding.ex new file mode 100644 index 00000000..220e196f --- /dev/null +++ b/lib/elixir_boilerplate_web/components/branding.ex @@ -0,0 +1,22 @@ +defmodule ElixirBoilerplateWeb.Components.Branding do + @moduledoc """ + Provides branding UI components. + """ + use Phoenix.Component + + @doc """ + Renders the boilerplate logo as an inlined SVG. + ## Examples + <.logo width=500 /> + """ + attr :width, :integer, default: 500 + + def logo(assigns) do + ~H""" + + """ + end +end diff --git a/lib/elixir_boilerplate_web/components/core.ex b/lib/elixir_boilerplate_web/components/core.ex new file mode 100644 index 00000000..56fd82b5 --- /dev/null +++ b/lib/elixir_boilerplate_web/components/core.ex @@ -0,0 +1,568 @@ +defmodule ElixirBoilerplateWeb.Components.Core do + @moduledoc """ + Provides core UI components. + At the first glance, this module may seem daunting, but its goal is + to provide some core building blocks in your application, such modals, + tables, and forms. The components are mostly markup and well documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + import ElixirBoilerplate.Gettext + + @doc """ + Renders a modal. + ## Examples + <.modal id="confirm-modal"> + This is a modal. + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + <%= render_slot(@inner_block) %> +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ <%= render_slot(@inner_block) %> +

+

+ <%= render_slot(@subtitle) %> +

+
+
<%= render_slot(@actions) %>
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + ## Examples + <.table id="users" rows={@users}> + <:col :let={user} label="id"><%= user.id %> + <:col :let={user} label="username"><%= user.username %> + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
<%= col[:label] %><%= gettext("Actions") %>
+
+ + + <%= render_slot(col, @row_item.(row)) %> + +
+
+
+ + + <%= render_slot(action, @row_item.(row)) %> + +
+
+
+ """ + end + + @doc """ + Renders a data list. + ## Examples + <.list> + <:item title="Title"><%= @post.title %> + <:item title="Views"><%= @post.views %> + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
<%= item.title %>
+
<%= render_slot(item) %>
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + ## Examples + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link navigate={@navigate} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"> + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + <%= render_slot(@inner_block) %> + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + You can customize the size and colors of the icons by setting + width, height, and background color classes. + Icons are extracted from your `assets/vendor/heroicons` directory and bundled + within your compiled app.css by the plugin in your `assets/tailwind.config.js`. + ## Examples + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + transition: {"transition-all transform ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: {"transition-all transform ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(ElixirBoilerplate.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(ElixirBoilerplate.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/lib/elixir_boilerplate_web/controller.ex b/lib/elixir_boilerplate_web/controller.ex new file mode 100644 index 00000000..886af04a --- /dev/null +++ b/lib/elixir_boilerplate_web/controller.ex @@ -0,0 +1,15 @@ +defmodule ElixirBoilerplateWeb.Controller do + defmacro __using__(_opts) do + quote do + use Phoenix.Controller, + namespace: ElixirBoilerplateWeb, + formats: [:html, :json], + layouts: [html: ElixirBoilerplateWeb.Layouts] + + import Plug.Conn + import ElixirBoilerplate.Gettext + + unquote(ElixirBoilerplateWeb.verified_routes()) + end + end +end diff --git a/lib/elixir_boilerplate_web/elixir_boilerplate_web.ex b/lib/elixir_boilerplate_web/elixir_boilerplate_web.ex new file mode 100644 index 00000000..fb1645d3 --- /dev/null +++ b/lib/elixir_boilerplate_web/elixir_boilerplate_web.ex @@ -0,0 +1,29 @@ +defmodule ElixirBoilerplateWeb do + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + + # Core UI components and translation + import ElixirBoilerplate.Gettext + import ElixirBoilerplateWeb.Components.Core + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: ElixirBoilerplateWeb.Endpoint, + router: ElixirBoilerplateWeb.Router, + statics: ElixirBoilerplateWeb.static_paths() + end + end +end diff --git a/lib/elixir_boilerplate_web/endpoint.ex b/lib/elixir_boilerplate_web/endpoint.ex index 2d597929..a52106b2 100644 --- a/lib/elixir_boilerplate_web/endpoint.ex +++ b/lib/elixir_boilerplate_web/endpoint.ex @@ -7,9 +7,14 @@ defmodule ElixirBoilerplateWeb.Endpoint do @plug_ssl Plug.SSL.init(rewrite_on: [:x_forwarded_proto], subdomains: true) socket("/socket", ElixirBoilerplateWeb.Socket) - socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: {ElixirBoilerplateWeb.Session, :config, []}]]) - plug(ElixirBoilerplateWeb.Plugs.Security) + socket "/live", Phoenix.LiveView.Socket, + websocket: [ + connect_info: [ + session: {ElixirBoilerplateWeb.Session, :config, []} + ] + ] + plug(:ping) plug(:canonical_host) plug(:force_ssl) @@ -24,7 +29,7 @@ defmodule ElixirBoilerplateWeb.Endpoint do at: "/", from: :elixir_boilerplate, gzip: true, - only: ~w(assets fonts images favicon.ico robots.txt) + only: ElixirBoilerplateWeb.static_paths() ) # Code reloading can be explicitly enabled under the @@ -48,9 +53,8 @@ defmodule ElixirBoilerplateWeb.Endpoint do plug(Plug.MethodOverride) plug(Plug.Head) - plug(ElixirBoilerplateHealth.Router) - plug(ElixirBoilerplateGraphQL.Router) - plug(:halt_if_sent) + plug(:session) + plug(ElixirBoilerplateWeb.Router) @doc """ @@ -115,9 +119,9 @@ defmodule ElixirBoilerplateWeb.Endpoint do end end - # Splitting routers in separate modules has a negative side effect: - # Phoenix.Router does not check the Plug.Conn state and tries to match the - # route even if it was already handled/sent by another router. - defp halt_if_sent(%{state: :sent, halted: false} = conn, _opts), do: halt(conn) - defp halt_if_sent(conn, _opts), do: conn + defp session(conn, _opts) do + opts = Plug.Session.init(ElixirBoilerplateWeb.Session.config()) + + Plug.Session.call(conn, opts) + end end diff --git a/lib/elixir_boilerplate_web/errors/error_html.ex b/lib/elixir_boilerplate_web/errors/error_html.ex new file mode 100644 index 00000000..54288506 --- /dev/null +++ b/lib/elixir_boilerplate_web/errors/error_html.ex @@ -0,0 +1,19 @@ +defmodule ElixirBoilerplateWeb.Errors.ErrorHTML do + use ElixirBoilerplateWeb.HTML + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/elixir_boilerplate_web/controllers/error_html/404.html.heex + # * lib/elixir_boilerplate_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/elixir_boilerplate_web/errors/error_json.ex b/lib/elixir_boilerplate_web/errors/error_json.ex new file mode 100644 index 00000000..7d916576 --- /dev/null +++ b/lib/elixir_boilerplate_web/errors/error_json.ex @@ -0,0 +1,15 @@ +defmodule ElixirBoilerplateWeb.Errors.ErrorJSON do + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/elixir_boilerplate_web/errors/templates/404.html.heex b/lib/elixir_boilerplate_web/errors/templates/404.html.heex index baa990de..7902e3fb 100644 --- a/lib/elixir_boilerplate_web/errors/templates/404.html.heex +++ b/lib/elixir_boilerplate_web/errors/templates/404.html.heex @@ -1,8 +1,8 @@ - - + + Not found diff --git a/lib/elixir_boilerplate_web/home/html.ex b/lib/elixir_boilerplate_web/home/html.ex index 615a3b45..fa7493dd 100644 --- a/lib/elixir_boilerplate_web/home/html.ex +++ b/lib/elixir_boilerplate_web/home/html.ex @@ -1,13 +1,13 @@ defmodule ElixirBoilerplateWeb.Home.HTML do - use Phoenix.Component + use ElixirBoilerplateWeb.HTML + + alias ElixirBoilerplateWeb.Components.Branding embed_templates("templates/*") def render("index.html", assigns), do: index(assigns) attr(:text, :string, required: true) + attr(:class, :string, default: nil) def message(assigns) - - attr(:url, :string, default: "https://github.com/mirego/elixir-boilerplate") - def header(assigns) end diff --git a/lib/elixir_boilerplate_web/home/live.ex b/lib/elixir_boilerplate_web/home/live.ex index 199d6a71..0e649a82 100644 --- a/lib/elixir_boilerplate_web/home/live.ex +++ b/lib/elixir_boilerplate_web/home/live.ex @@ -1,6 +1,5 @@ defmodule ElixirBoilerplateWeb.Home.Live do - @moduledoc false - use Phoenix.LiveView, layout: {ElixirBoilerplateWeb.Layouts, :live} + use ElixirBoilerplateWeb.LiveView def mount(_, _, socket) do socket = assign(socket, :message, "Hello, world!") @@ -22,7 +21,7 @@ defmodule ElixirBoilerplateWeb.Home.Live do end def handle_event("add_flash_success", _, socket) do - socket = put_flash(socket, :success, "Success: #{DateTime.utc_now()}") + socket = put_flash(socket, :info, "Success: #{DateTime.utc_now()}") {:noreply, socket} end diff --git a/lib/elixir_boilerplate_web/home/templates/header.html.heex b/lib/elixir_boilerplate_web/home/templates/header.html.heex deleted file mode 100644 index ae555aa0..00000000 --- a/lib/elixir_boilerplate_web/home/templates/header.html.heex +++ /dev/null @@ -1,5 +0,0 @@ - - - - -

This repository is the stable base upon which we build our Elixir projects at Mirego.
We want to share it with the world so you can build awesome Elixir applications too.

diff --git a/lib/elixir_boilerplate_web/home/templates/index.html.heex b/lib/elixir_boilerplate_web/home/templates/index.html.heex index 5f3e3743..432ac231 100644 --- a/lib/elixir_boilerplate_web/home/templates/index.html.heex +++ b/lib/elixir_boilerplate_web/home/templates/index.html.heex @@ -1,4 +1,18 @@ -
- <.header/> - <.message text={@message}/> -
+
+ <.flash_group flash={@flash} /> + +
+
+ + + + +

+ This repository is the stable base upon which we build our Elixir projects at Mirego.
+ We want to share it with the world so you can build awesome Elixir applications too. +

+ + <.message class="pt-8" text={@message} /> +
+
+
diff --git a/lib/elixir_boilerplate_web/home/templates/index_live.html.heex b/lib/elixir_boilerplate_web/home/templates/index_live.html.heex index 940e3fb8..203d3f9f 100644 --- a/lib/elixir_boilerplate_web/home/templates/index_live.html.heex +++ b/lib/elixir_boilerplate_web/home/templates/index_live.html.heex @@ -1,17 +1,29 @@ -
- <.header/> - <.message text={@message}/> +
+ <.flash_group flash={@flash} /> -
+
+
+ + + -
- - <%= @counter %> - -
+

+ This repository is the stable base upon which we build our Elixir projects at Mirego.
+ We want to share it with the world so you can build awesome Elixir applications too. +

+ + <.message class="pt-8" text={@message} /> +
-
+
+ <.button type="button" phx-click="decrement_counter">- + <%= @counter %> + <.button type="button" phx-click="increment_counter">+ +
- - -
+
+ <.button phx-click="add_flash_success">Add flash success + <.button phx-click="add_flash_error">Add flash error +
+
+ diff --git a/lib/elixir_boilerplate_web/home/templates/message.html.heex b/lib/elixir_boilerplate_web/home/templates/message.html.heex index 55f309cf..94e172c7 100644 --- a/lib/elixir_boilerplate_web/home/templates/message.html.heex +++ b/lib/elixir_boilerplate_web/home/templates/message.html.heex @@ -1 +1,3 @@ -

Message: <%= @text %>

+

+ Message: <%= @text %> +

diff --git a/lib/elixir_boilerplate_web/html.ex b/lib/elixir_boilerplate_web/html.ex new file mode 100644 index 00000000..44c19ff9 --- /dev/null +++ b/lib/elixir_boilerplate_web/html.ex @@ -0,0 +1,14 @@ +defmodule ElixirBoilerplateWeb.HTML do + defmacro __using__(_opts) do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(ElixirBoilerplateWeb.html_helpers()) + end + end +end diff --git a/lib/elixir_boilerplate_web/layouts/layouts.ex b/lib/elixir_boilerplate_web/layouts/layouts.ex index cd2164b5..917f05b6 100644 --- a/lib/elixir_boilerplate_web/layouts/layouts.ex +++ b/lib/elixir_boilerplate_web/layouts/layouts.ex @@ -1,16 +1,8 @@ defmodule ElixirBoilerplateWeb.Layouts do - @moduledoc false - use Phoenix.Component - - alias ElixirBoilerplateWeb.Router.Helpers, as: Routes - alias Phoenix.LiveView.JS + use ElixirBoilerplateWeb.HTML embed_templates("templates/*") - attr(:flash, :map, required: true) - attr(:kind, :atom, required: true) - def flash(assigns) - def hide_flash(id) do "lv:clear-flash" |> JS.push() diff --git a/lib/elixir_boilerplate_web/layouts/templates/app.html.heex b/lib/elixir_boilerplate_web/layouts/templates/app.html.heex index 0d09a6c5..ff3178bf 100644 --- a/lib/elixir_boilerplate_web/layouts/templates/app.html.heex +++ b/lib/elixir_boilerplate_web/layouts/templates/app.html.heex @@ -1,9 +1,3 @@
-
- <.flash flash={@flash} kind={:success} /> - <.flash flash={@flash} kind={:error} /> - <.flash flash={@flash} kind={:info} /> -
- <%= @inner_content %>
diff --git a/lib/elixir_boilerplate_web/layouts/templates/flash.html.heex b/lib/elixir_boilerplate_web/layouts/templates/flash.html.heex deleted file mode 100644 index 6f2af775..00000000 --- a/lib/elixir_boilerplate_web/layouts/templates/flash.html.heex +++ /dev/null @@ -1,8 +0,0 @@ -
to_string(@kind)} - phx-click={hide_flash("#" <> "flash-" <> to_string(@kind))} - phx-hook="Flash" -> - <%= msg %> -
diff --git a/lib/elixir_boilerplate_web/layouts/templates/live.html.heex b/lib/elixir_boilerplate_web/layouts/templates/live.html.heex index 0d09a6c5..d2cdafd8 100644 --- a/lib/elixir_boilerplate_web/layouts/templates/live.html.heex +++ b/lib/elixir_boilerplate_web/layouts/templates/live.html.heex @@ -1,8 +1,6 @@
- <.flash flash={@flash} kind={:success} /> - <.flash flash={@flash} kind={:error} /> - <.flash flash={@flash} kind={:info} /> + <.flash_group flash={@flash} />
<%= @inner_content %> diff --git a/lib/elixir_boilerplate_web/layouts/templates/root.html.heex b/lib/elixir_boilerplate_web/layouts/templates/root.html.heex index 3100e3c8..623935bc 100644 --- a/lib/elixir_boilerplate_web/layouts/templates/root.html.heex +++ b/lib/elixir_boilerplate_web/layouts/templates/root.html.heex @@ -2,14 +2,19 @@ - - - - - + + + + <.live_title suffix=" · ElixirBoilerplate"> + <%= assigns[:page_title] || "ElixirBoilerplate" %> + + + + - + <%= @inner_content %> diff --git a/lib/elixir_boilerplate_web/live_view.ex b/lib/elixir_boilerplate_web/live_view.ex new file mode 100644 index 00000000..4c3b211a --- /dev/null +++ b/lib/elixir_boilerplate_web/live_view.ex @@ -0,0 +1,9 @@ +defmodule ElixirBoilerplateWeb.LiveView do + defmacro __using__(_opts) do + quote do + use Phoenix.LiveView, layout: {ElixirBoilerplateWeb.Layouts, :live} + + unquote(ElixirBoilerplateWeb.html_helpers()) + end + end +end diff --git a/lib/elixir_boilerplate_web/plugs/security.ex b/lib/elixir_boilerplate_web/plugs/security.ex index 315c55dd..a95237fe 100644 --- a/lib/elixir_boilerplate_web/plugs/security.ex +++ b/lib/elixir_boilerplate_web/plugs/security.ex @@ -33,10 +33,17 @@ defmodule ElixirBoilerplateWeb.Plugs.Security do defp media_src_directive, do: "'self'" defp font_src_directive, do: "'self'" defp connect_src_directive, do: "'self'" - defp style_src_directive, do: "'self' 'unsafe-inline'" defp frame_src_directive, do: "'self'" defp image_src_directive, do: "'self' data:" + defp style_src_directive do + if Application.get_env(:elixir_boilerplate, __MODULE__)[:allow_unsafe_scripts] do + "'self' 'unsafe-inline'" + else + "'self'" + end + end + defp script_src_directive do if Application.get_env(:elixir_boilerplate, __MODULE__)[:allow_unsafe_scripts] do "'self' 'unsafe-eval' 'unsafe-inline'" diff --git a/lib/elixir_boilerplate_web/router.ex b/lib/elixir_boilerplate_web/router.ex index 06e3affd..8313de0b 100644 --- a/lib/elixir_boilerplate_web/router.ex +++ b/lib/elixir_boilerplate_web/router.ex @@ -1,6 +1,9 @@ defmodule ElixirBoilerplateWeb.Router do - use Phoenix.Router + use Phoenix.Router, helpers: false + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller import Phoenix.LiveView.Router pipeline :browser do @@ -11,20 +14,24 @@ defmodule ElixirBoilerplateWeb.Router do json_decoder: Phoenix.json_library() ) - plug(:accepts, ["html", "json"]) + plug(:accepts, ~w[html json]) - plug(:session) plug(:fetch_session) + plug(:fetch_live_flash) plug(:protect_from_forgery) - plug(:fetch_live_flash) + plug(ElixirBoilerplateWeb.Plugs.Security) plug(:put_layout, {ElixirBoilerplateWeb.Layouts, :app}) plug(:put_root_layout, {ElixirBoilerplateWeb.Layouts, :root}) end + pipeline :api do + plug(:accepts, ~w[json]) + end + scope "/" do - pipe_through(:browser) + pipe_through :browser # To enable metrics dashboard use `telemetry_ui_allowed: true` as assigns value # @@ -34,22 +41,41 @@ defmodule ElixirBoilerplateWeb.Router do end scope "/", ElixirBoilerplateWeb do - pipe_through(:browser) + pipe_through :browser get("/", Home.Controller, :index, as: :home) + live("/live", Home.Live, :index, as: :live_home) end - scope "/", ElixirBoilerplateWeb do - pipe_through(:browser) + scope "/api", ElixirBoilerplateWeb.Api do + pipe_through :api - live("/live", Home.Live, :index, as: :live_home) + get("/version", Version.Controller, :index, as: :version) end - # The session will be stored in the cookie and signed, - # this means its contents can be read but not tampered with. - # Set :encryption_salt if you would also like to encrypt it. - defp session(conn, _opts) do - opts = Plug.Session.init(ElixirBoilerplateWeb.Session.config()) - Plug.Session.call(conn, opts) + scope "/" do + pipe_through :api + + forward("/graphql", Absinthe.Plug, schema: ElixirBoilerplateGraphQL.Schema) + + if Mix.env() == :dev do + forward("/graphiql", Absinthe.Plug.GraphiQL, + schema: ElixirBoilerplateGraphQL.Schema, + socket: ElixirBoilerplateWeb.Socket, + interface: :playground + ) + end end + + forward( + "/health", + PlugCheckup, + PlugCheckup.Options.new( + json_encoder: Phoenix.json_library(), + checks: ElixirBoilerplateHealth.checks(), + error_code: ElixirBoilerplateHealth.error_code(), + timeout: :timer.seconds(5), + pretty: false + ) + ) end diff --git a/lib/elixir_boilerplate_web/session.ex b/lib/elixir_boilerplate_web/session.ex index 643a721b..08e0d922 100644 --- a/lib/elixir_boilerplate_web/session.ex +++ b/lib/elixir_boilerplate_web/session.ex @@ -9,6 +9,6 @@ defmodule ElixirBoilerplateWeb.Session do end defp app_config(key) do - Keyword.fetch!(Application.get_env(:elixir_boilerplate, ElixirBoilerplateWeb.Endpoint), key) + Keyword.fetch!(Application.get_env(:elixir_boilerplate, __MODULE__), key) end end diff --git a/mix.exs b/mix.exs index 6503f4c8..963cc71e 100644 --- a/mix.exs +++ b/mix.exs @@ -5,8 +5,8 @@ defmodule ElixirBoilerplate.Mixfile do [ app: :elixir_boilerplate, version: "0.0.1", - erlang: "~> 25.0", - elixir: "~> 1.13", + erlang: "~> 26.0", + elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), test_paths: ["test"], test_pattern: "**/*_test.exs", @@ -32,10 +32,10 @@ defmodule ElixirBoilerplate.Mixfile do defp aliases do [ - "assets.deploy": [ - "esbuild default --minify", - "phx.digest" - ], + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind default", "esbuild default"], + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate", "test"] @@ -46,6 +46,7 @@ defmodule ElixirBoilerplate.Mixfile do [ # Assets bundling {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, # HTTP Client {:hackney, "~> 1.18"}, diff --git a/mix.lock b/mix.lock index 14c8bb38..f5420411 100644 --- a/mix.lock +++ b/mix.lock @@ -39,6 +39,7 @@ "new_relic_absinthe": {:hex, :new_relic_absinthe, "0.0.4", "57917f99789d9b36e4beb599deba495a474e5bf99a5c70a33717b0e17f1c5d4d", [:mix], [{:absinthe, "~> 1.4", [hex: :absinthe, repo: "hexpm", optional: false]}, {:new_relic_agent, "~> 1.19", [hex: :new_relic_agent, repo: "hexpm", optional: false]}], "hexpm", "6b796662e550ddd07e98ff3df95803a6b2a023605e78e0a45261d3e66341c296"}, "new_relic_agent": {:hex, :new_relic_agent, "1.28.0", "eb015edb4f4887a31ee0488cf9ce97b7e46c9e0208eeff8737d8ea09cd506d09", [:mix], [{:castore, ">= 0.1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ecto, ">= 3.4.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, ">= 3.4.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.5", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.10.4", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.4.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:redix, ">= 0.11.0", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef220f429f2d673e78679ec96cd4e8979b2cb2166b204bfe1a43ca974520743a"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "observer_cli": {:hex, :observer_cli, "1.7.4", "3c1bfb6d91bf68f6a3d15f46ae20da0f7740d363ee5bc041191ce8722a6c4fae", [:mix, :rebar3], [{:recon, "~> 2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "50de6d95d814f447458bd5d72666a74624eddb0ef98bdcee61a0153aae0865ff"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, @@ -55,11 +56,13 @@ "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "recon": {:hex, :recon, "2.5.4", "05dd52a119ee4059fa9daa1ab7ce81bc7a8161a2f12e9d42e9d551ffd2ba901c", [:mix, :rebar3], [], "hexpm", "e9ab01ac7fc8572e41eb59385efeb3fb0ff5bf02103816535bacaedf327d0263"}, "sentry": {:hex, :sentry, "9.1.0", "8689b85774003ddcebfd9d48a93bc3f3bf72223983514521aa30645c6f204f86", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "d70c88ab0c6a511594856ae2244d1bd70b8b7a4a42201a3569880f1dd2a3adec"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "styler": {:hex, :styler, "0.11.6", "ad4c5fc1ff72b93107ecb251f595b316c6d97604b742f3473aa036888592b270", [:mix], [], "hexpm", "0b0b9936e91b01a7a9fd7239902581ed1cb5515254357126429a37d1bb3d0078"}, "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, + "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, diff --git a/test/elixir_boilerplate_web/errors/error_html_test.exs b/test/elixir_boilerplate_web/errors/error_html_test.exs new file mode 100644 index 00000000..f0a83732 --- /dev/null +++ b/test/elixir_boilerplate_web/errors/error_html_test.exs @@ -0,0 +1,17 @@ +# credo:disable-for-this-line CredoNaming.Check.Consistency.ModuleFilename +defmodule ElixirBoilerplateWeb.Errors.ErrorHtmlTest do + use ElixirBoilerplate.DataCase, async: true + + # Bring render_to_string/3 for testing custom views + import Phoenix.Template + + alias ElixirBoilerplateWeb.Errors.ErrorHTML + + test "renders 404.html" do + assert render_to_string(ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/elixir_boilerplate_web/errors/error_json_test.exs b/test/elixir_boilerplate_web/errors/error_json_test.exs new file mode 100644 index 00000000..27814a14 --- /dev/null +++ b/test/elixir_boilerplate_web/errors/error_json_test.exs @@ -0,0 +1,14 @@ +# credo:disable-for-this-file CredoNaming.Check.Consistency.ModuleFilename +defmodule ElixirBoilerplateWeb.Errors.ErrorJsonTest do + use ElixirBoilerplate.DataCase, async: true + + alias ElixirBoilerplateWeb.Errors.ErrorJSON + + test "renders 404" do + assert ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert ErrorJSON.render("500.json", %{}) == %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/elixir_boilerplate_web/errors_test.exs b/test/elixir_boilerplate_web/errors/errors_test.exs similarity index 96% rename from test/elixir_boilerplate_web/errors_test.exs rename to test/elixir_boilerplate_web/errors/errors_test.exs index 1c407caa..d2be3886 100644 --- a/test/elixir_boilerplate_web/errors_test.exs +++ b/test/elixir_boilerplate_web/errors/errors_test.exs @@ -1,8 +1,6 @@ defmodule ElixirBoilerplateWeb.ErrorsTest do use ElixirBoilerplate.DataCase, async: true - alias ElixirBoilerplateWeb.Errors - defmodule UserRole do @moduledoc false use Ecto.Schema @@ -76,7 +74,7 @@ defmodule ElixirBoilerplateWeb.ErrorsTest do defp changeset_to_error_messages(changeset) do changeset - |> Errors.changeset_to_error_messages() + |> ElixirBoilerplateWeb.Errors.changeset_to_error_messages() |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() end