diff --git a/.gitignore b/.gitignore index 61d6650f..cc0e5067 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ npm-debug.log # Ignore assets that are produced by build tools. /priv/static/assets +# Ignore digested assets cache. +/priv/static/cache_manifest.json + # Code coverage from React tests /assets/coverage/ diff --git a/Dockerfile b/Dockerfile index 19822365..16e889f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,9 +42,10 @@ RUN mix deps.compile COPY assets assets RUN npm ci --prefix assets -RUN mix assets.deploy COPY lib lib +RUN mix assets.deploy + COPY priv priv RUN mix phx.digest diff --git a/assets/css/app.css b/assets/css/app.css index 907f3aad..72558260 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,8 +1,12 @@ /* This file is for your main application css. */ +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; @import "./variables.css"; @import "./bootstrap.css"; @import "../node_modules/react-datepicker/dist/react-datepicker.css"; +@import "../node_modules/@fullcalendar/daygrid/main.css"; +@import "../node_modules/@fullcalendar/common/main.css"; @import "./base.css"; @import "./checkbox.css"; diff --git a/assets/package-lock.json b/assets/package-lock.json index d0e88072..f1e39eb2 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -47,6 +47,7 @@ "version": "3.3.4" }, "../deps/react_phoenix": { + "name": "react-phoenix", "version": "1.3.1", "license": "MIT", "devDependencies": { diff --git a/assets/package.json b/assets/package.json index 5b14d6e9..9d8e0700 100644 --- a/assets/package.json +++ b/assets/package.json @@ -45,9 +45,6 @@ "jest": { "testEnvironment": "jsdom", "clearMocks": true, - "transformIgnorePatterns": [ - "node_modules/(?!@fullcalendar).+" - ], "transform": { "^.+\\.(j|t)sx?$": "ts-jest" }, @@ -63,9 +60,6 @@ "json", "node" ], - "moduleNameMapper": { - "\\.(css)$": "identity-obj-proxy" - }, "setupFilesAfterEnv": [ "/jest-setup.ts" ], diff --git a/assets/src/app.tsx b/assets/src/app.tsx index 235bb0a3..602f6928 100644 --- a/assets/src/app.tsx +++ b/assets/src/app.tsx @@ -1,11 +1,3 @@ -// We need to import the CSS so that webpack will load it. -// The MiniCssExtractPlugin is used to separate it out into -// its own CSS file. -declare function require(name: string): string - -// tslint:disable-next-line -require("../css/app.css") - import "phoenix_html" import ReactPhoenix from "./ReactPhoenix" diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 00000000..51fd61cd --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,94 @@ +// 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/arrow_web.ex", "../lib/arrow_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 &", + ]) + ), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function ({ matchComponents, theme }) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"], + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { + let name = path.basename(file, ".svg") + suffix + values[name] = { name, fullPath: path.join(iconsDir, dir, file) } + }) + }) + matchComponents( + { + hero: ({ name, fullPath }) => { + let content = fs + .readFileSync(fullPath) + .toString() + .replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + 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: size, + height: size, + } + }, + }, + { values } + ) + }), + ], +} diff --git a/assets/tsconfig.json b/assets/tsconfig.json index c2fec3c5..f88f2c55 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src", "tests", "node_modules/@fullcalendar", "jest-setup.ts"], + "include": ["src", "tests", "jest-setup.ts"], "exclude": ["node_modules"], "compilerOptions": { /* Basic Options */ diff --git a/config/config.exs b/config/config.exs index cd07d2c0..567501bf 100644 --- a/config/config.exs +++ b/config/config.exs @@ -43,21 +43,35 @@ config :arrow, ArrowWeb.Endpoint, pubsub_server: Arrow.PubSub config :esbuild, - version: "0.12.18", + version: "0.17.11", default: [ args: ~w( src/app.tsx --bundle --target=es2015 - --loader:.png=file - --loader:.woff=file + --loader:.css=empty --outdir=../priv/static/assets + --external:/fonts/* + --external:/images/* + --external:/css/* #{if(Mix.env() == :test, do: "--define:__REACT_DEVTOOLS_GLOBAL_HOOK__={'isDisabled':true}")} ), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.0", + default: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + config :arrow, ArrowWeb.AuthManager, issuer: "arrow" config :ueberauth, Ueberauth, @@ -98,4 +112,4 @@ config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. -import_config "#{Mix.env()}.exs" +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 56de819a..34102fe1 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -23,7 +23,9 @@ config :arrow, ArrowWeb.Endpoint, secret_key_base: "local_secret_key_base_at_least_64_bytes_________________________________", watchers: [ esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, - node: ~w(assets/node_modules/.bin/tsc --project assets --noEmit --watch --preserveWatchOutput) + node: + ~w(assets/node_modules/.bin/tsc --project assets --noEmit --watch --preserveWatchOutput), + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} ] config :arrow, ArrowWeb.AuthManager, secret_key: "test key" @@ -58,8 +60,7 @@ config :arrow, ArrowWeb.Endpoint, patterns: [ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", - ~r"lib/arrow_web/{live,views}/.*(ex)$", - ~r"lib/arrow_web/templates/.*(eex)$" + ~r"lib/arrow_web/(controllers|live|components)/.*(ex|heex)$" ] ] @@ -70,6 +71,9 @@ config :ueberauth, Ueberauth, config :arrow, :redirect_http?, false +# Enable dev routes for dashboard and mailbox +config :arrow, dev_routes: true + # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" @@ -79,3 +83,10 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true + +# Enable helpful, but potentially expensive runtime checks +# enable_expensive_runtime_checks: true diff --git a/lib/arrow/shuttle.ex b/lib/arrow/shuttle.ex new file mode 100644 index 00000000..d43a90f2 --- /dev/null +++ b/lib/arrow/shuttle.ex @@ -0,0 +1,104 @@ +defmodule Arrow.Shuttle do + @moduledoc """ + The Shuttle context. + """ + + import Ecto.Query, warn: false + alias Arrow.Repo + + alias Arrow.Shuttle.Shape + + @doc """ + Returns the list of shapes. + + ## Examples + + iex> list_shapes() + [%Shape{}, ...] + + """ + def list_shapes do + Repo.all(Shape) + end + + @doc """ + Gets a single shape. + + Raises `Ecto.NoResultsError` if the Shape does not exist. + + ## Examples + + iex> get_shape!(123) + %Shape{} + + iex> get_shape!(456) + ** (Ecto.NoResultsError) + + """ + def get_shape!(id), do: Repo.get!(Shape, id) + + @doc """ + Creates a shape. + + ## Examples + + iex> create_shape(%{field: value}) + {:ok, %Shape{}} + + iex> create_shape(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_shape(attrs \\ %{}) do + %Shape{} + |> Shape.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a shape. + + ## Examples + + iex> update_shape(shape, %{field: new_value}) + {:ok, %Shape{}} + + iex> update_shape(shape, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_shape(%Shape{} = shape, attrs) do + shape + |> Shape.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a shape. + + ## Examples + + iex> delete_shape(shape) + {:ok, %Shape{}} + + iex> delete_shape(shape) + {:error, %Ecto.Changeset{}} + + """ + def delete_shape(%Shape{} = shape) do + Repo.delete(shape) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking shape changes. + + ## Examples + + iex> change_shape(shape) + %Ecto.Changeset{data: %Shape{}} + + """ + def change_shape(%Shape{} = shape, attrs \\ %{}) do + Shape.changeset(shape, attrs) + end +end diff --git a/lib/arrow/shuttle/shape.ex b/lib/arrow/shuttle/shape.ex new file mode 100644 index 00000000..7f605e9a --- /dev/null +++ b/lib/arrow/shuttle/shape.ex @@ -0,0 +1,19 @@ +defmodule Arrow.Shuttle.Shape do + @moduledoc "schema for shuttle shapes" + use Ecto.Schema + import Ecto.Changeset + + schema "shapes" do + field :name, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(shape, attrs) do + shape + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> unique_constraint(:name) + end +end diff --git a/lib/arrow_web.ex b/lib/arrow_web.ex index be6e1e32..1af69e9a 100644 --- a/lib/arrow_web.ex +++ b/lib/arrow_web.ex @@ -18,6 +18,22 @@ defmodule ArrowWeb do below. Instead, define any helper function in modules and import those modules here. """ + def static_paths, do: ~w(assets fonts images icons favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + import ArrowWeb.Gettext + end + end def controller do quote do @@ -26,12 +42,21 @@ defmodule ArrowWeb do import Plug.Conn import ArrowWeb.Gettext alias ArrowWeb.Router.Helpers, as: Routes + + unquote(verified_routes()) end end def html 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(html_helpers()) + # Use all HTML functionality (forms, tags, etc) # Still needed for old style Phoenix HTML like , use Phoenix.HTML @@ -45,18 +70,28 @@ defmodule ArrowWeb do end end - def router do + defp html_helpers do quote do - use Phoenix.Router - import Plug.Conn - import Phoenix.Controller + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import ArrowWeb.CoreComponents + import ArrowWeb.Gettext + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) end end - def channel do + def verified_routes do quote do - use Phoenix.Channel - import ArrowWeb.Gettext + use Phoenix.VerifiedRoutes, + endpoint: ArrowWeb.Endpoint, + router: ArrowWeb.Router, + statics: ArrowWeb.static_paths() end end diff --git a/lib/arrow_web/components/core_components.ex b/lib/arrow_web/components/core_components.ex new file mode 100644 index 00000000..bebd1d9c --- /dev/null +++ b/lib/arrow_web/components/core_components.ex @@ -0,0 +1,676 @@ +defmodule ArrowWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are 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 ArrowWeb.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 the `deps/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(ArrowWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(ArrowWeb.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/arrow_web/controllers/feed_html/index.html.heex b/lib/arrow_web/controllers/feed_html/index.html.heex index acb28086..a927a9cf 100644 --- a/lib/arrow_web/controllers/feed_html/index.html.heex +++ b/lib/arrow_web/controllers/feed_html/index.html.heex @@ -1,4 +1,3 @@ -

Feed

diff --git a/lib/arrow_web/controllers/shape_controller.ex b/lib/arrow_web/controllers/shape_controller.ex new file mode 100644 index 00000000..6880d4e6 --- /dev/null +++ b/lib/arrow_web/controllers/shape_controller.ex @@ -0,0 +1,74 @@ +defmodule ArrowWeb.ShapeController do + use ArrowWeb, :controller + + alias Arrow.Shuttle + alias Arrow.Shuttle.Shape + alias ArrowWeb.Plug.Authorize + + plug(Authorize, :view_disruption when action in [:index, :show]) + plug(Authorize, :create_disruption when action in [:new, :create]) + plug(Authorize, :update_disruption when action in [:edit, :update, :update_row_status]) + plug(Authorize, :delete_disruption when action in [:delete]) + + def index(conn, _params) do + shapes = Shuttle.list_shapes() + render(conn, :index, shapes: shapes) + end + + def new(conn, _params) do + changeset = Shuttle.change_shape(%Shape{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"shape" => shape_params}) do + case Shuttle.create_shape(shape_params) do + {:ok, shape} -> + conn + |> put_flash( + :info, + "Shape created successfully from #{shape_params["filename"].filename}" + ) + |> redirect(to: ~p"/shapes/#{shape}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + shape = Shuttle.get_shape!(id) + render(conn, :show, shape: shape) + end + + def edit(conn, %{"id" => id}) do + shape = Shuttle.get_shape!(id) + changeset = Shuttle.change_shape(shape) + render(conn, :edit, shape: shape, changeset: changeset) + end + + def update(conn, %{"id" => id, "shape" => shape_params}) do + shape = Shuttle.get_shape!(id) + + case Shuttle.update_shape(shape, shape_params) do + {:ok, shape} -> + conn + |> put_flash( + :info, + "Shape updated successfully from #{shape_params["filename"].filename}" + ) + |> redirect(to: ~p"/shapes/#{shape}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, shape: shape, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + shape = Shuttle.get_shape!(id) + {:ok, _shape} = Shuttle.delete_shape(shape) + + conn + |> put_flash(:info, "Shape deleted successfully.") + |> redirect(to: ~p"/shapes") + end +end diff --git a/lib/arrow_web/controllers/shape_html.ex b/lib/arrow_web/controllers/shape_html.ex new file mode 100644 index 00000000..0495a56c --- /dev/null +++ b/lib/arrow_web/controllers/shape_html.ex @@ -0,0 +1,13 @@ +defmodule ArrowWeb.ShapeView do + use ArrowWeb, :html + + embed_templates "shape_html/*" + + @doc """ + Renders a shape form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def shape_form(assigns) +end diff --git a/lib/arrow_web/controllers/shape_html/edit.html.heex b/lib/arrow_web/controllers/shape_html/edit.html.heex new file mode 100644 index 00000000..1147bdbe --- /dev/null +++ b/lib/arrow_web/controllers/shape_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Shape <%= @shape.id %> + <:subtitle>Use this form to manage shape records in your database. + + +<.shape_form changeset={@changeset} action={~p"/shapes/#{@shape}"} /> + +<.back navigate={~p"/shapes"}>Back to shapes diff --git a/lib/arrow_web/controllers/shape_html/index.html.heex b/lib/arrow_web/controllers/shape_html/index.html.heex new file mode 100644 index 00000000..5201018f --- /dev/null +++ b/lib/arrow_web/controllers/shape_html/index.html.heex @@ -0,0 +1,23 @@ +<.header> + Listing Shapes + <:actions> + <.link href={~p"/shapes/new"}> + <.button>New Shape + + + + +<.table id="shapes" rows={@shapes} row_click={&JS.navigate(~p"/shapes/#{&1}")}> + <:col :let={shape} label="Name"><%= shape.name %> + <:action :let={shape}> +
+ <.link navigate={~p"/shapes/#{shape}"}>Show +
+ <.link navigate={~p"/shapes/#{shape}/edit"}>Edit + + <:action :let={shape}> + <.link href={~p"/shapes/#{shape}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/arrow_web/controllers/shape_html/new.html.heex b/lib/arrow_web/controllers/shape_html/new.html.heex new file mode 100644 index 00000000..e4b92fa2 --- /dev/null +++ b/lib/arrow_web/controllers/shape_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Shape + <:subtitle>Use this form to manage shape records in your database. + + +<.shape_form changeset={@changeset} action={~p"/shapes"} /> + +<.back navigate={~p"/shapes"}>Back to shapes diff --git a/lib/arrow_web/controllers/shape_html/shape_form.html.heex b/lib/arrow_web/controllers/shape_html/shape_form.html.heex new file mode 100644 index 00000000..93633ff8 --- /dev/null +++ b/lib/arrow_web/controllers/shape_html/shape_form.html.heex @@ -0,0 +1,10 @@ +<.simple_form :let={f} for={@changeset} action={@action} multipart> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:filename]} type="file" label="Filename" required="true"/> + <.input field={f[:name]} type="text" label="Name" /> + <:actions> + <.button>Save Shape + + diff --git a/lib/arrow_web/controllers/shape_html/show.html.heex b/lib/arrow_web/controllers/shape_html/show.html.heex new file mode 100644 index 00000000..86e5481e --- /dev/null +++ b/lib/arrow_web/controllers/shape_html/show.html.heex @@ -0,0 +1,15 @@ +<.header> + Shape <%= @shape.id %> + <:subtitle>This is a shape record from your database. + <:actions> + <.link href={~p"/shapes/#{@shape}/edit"}> + <.button>Edit shape + + + + +<.list> + <:item title="Name"><%= @shape.name %> + + +<.back navigate={~p"/shapes"}>Back to shapes diff --git a/lib/arrow_web/endpoint.ex b/lib/arrow_web/endpoint.ex index 4aa8f3d9..c44969d6 100644 --- a/lib/arrow_web/endpoint.ex +++ b/lib/arrow_web/endpoint.ex @@ -18,7 +18,7 @@ defmodule ArrowWeb.Endpoint do at: "/", from: :arrow, gzip: false, - only: ~w(assets fonts images icons favicon.ico robots.txt) + only: ArrowWeb.static_paths() # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. diff --git a/lib/arrow_web/router.ex b/lib/arrow_web/router.ex index 86540a27..b1f8a722 100644 --- a/lib/arrow_web/router.ex +++ b/lib/arrow_web/router.ex @@ -48,6 +48,7 @@ defmodule ArrowWeb.Router do resources("/disruptions", DisruptionController, except: [:index]) put("/disruptions/:id/row_status", DisruptionController, :update_row_status) post("/disruptions/:id/notes", NoteController, :create) + resources("/shapes", ShapeController) end scope "/", ArrowWeb do diff --git a/mix.exs b/mix.exs index c5810ded..fbbac9de 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,15 @@ defmodule Arrow.MixProject do {:ueberauth_oidcc, "~> 0.4.0"}, {:ueberauth, "~> 0.10"}, {:wallaby, "~> 0.30.6", runtime: false, only: :test}, - {:sentry, "~> 8.0"} + {:sentry, "~> 8.0"}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1} ] end @@ -92,8 +100,9 @@ defmodule Arrow.MixProject do "ecto.reset": ["ecto.drop", "ecto.setup"], "ecto.rollback": ["ecto.rollback", "ecto.dump --quiet"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], - "assets.build": ["esbuild default --sourcemap=inline"], - "assets.deploy": ["esbuild default --minify", "phx.digest"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["esbuild default --sourcemap=inline", "tailwind default"], + "assets.deploy": ["esbuild default --minify", "tailwind default --minify", "phx.digest"], "test.integration": [ "assets.build", "ecto.create --quiet", diff --git a/mix.lock b/mix.lock index 582be2d1..8bf2ce48 100644 --- a/mix.lock +++ b/mix.lock @@ -13,7 +13,7 @@ "ecto": {:hex, :ecto, "3.11.0", "ff8614b4e70a774f9d39af809c426def80852048440e8785d93a6e91f48fec00", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7769dad267ef967310d6e988e92d772659b11b09a0c015f101ce0fff81ce1f81"}, "ecto_sql": {:hex, :ecto_sql, "3.11.0", "c787b24b224942b69c9ff7ab9107f258ecdc68326be04815c6cce2941b6fad1c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "77aa3677169f55c2714dda7352d563002d180eb33c0dc29cd36d39c0a1a971f5"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, + "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "ex_aws": {:hex, :ex_aws, "2.3.2", "37d6c9d81b641508e1722e69ace6ae7f9f6b3c7984e769e623591f4b2ead766a", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d18fdd9d6827e52e1097b07655fd08977516a2e64e9355c2bcaa30ad092e0ae5"}, "ex_aws_rds": {:hex, :ex_aws_rds, "2.0.2", "38dd8e83d57cf4b7286c4f6f5c978f700c40c207ffcdd6ca5d738e5eba933f9a", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "9e5b5cc168077874cbd0d29ba65d01caf1877e705fb5cecacf0667dd19bfa75c"}, "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, @@ -24,6 +24,7 @@ "guardian": {:hex, :guardian, "2.3.1", "2b2d78dc399a7df182d739ddc0e566d88723299bfac20be36255e2d052fd215d", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bbe241f9ca1b09fad916ad42d6049d2600bbc688aba5b3c4a6c82592a54274c3"}, "guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "21f439246715192b231f228680465d1ed5fbdf01555a4a3b17165532f5f9a08c"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm", "7b5ccb9b720c26516f5962dc4565fc26f083ca107b0f6c167048506a125d2df3"}, @@ -54,6 +55,7 @@ "react_phoenix": {:hex, :react_phoenix, "1.3.1", "b2abb625ce7304a3b2ac5eea3126c30ef1e1c860f9ef27290ee726ab1fa3a87a", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.11 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "a10de67f02f6c5cf04f6987cef7413400d671936b4df97e0b9ac3c227ba27e6b"}, "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [: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", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "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.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, diff --git a/priv/repo/migrations/20240605185923_create_shapes.exs b/priv/repo/migrations/20240605185923_create_shapes.exs new file mode 100644 index 00000000..68aeab14 --- /dev/null +++ b/priv/repo/migrations/20240605185923_create_shapes.exs @@ -0,0 +1,13 @@ +defmodule Arrow.Repo.Migrations.CreateShapes do + use Ecto.Migration + + def change do + create table(:shapes) do + add :name, :string + + timestamps(type: :timestamptz) + end + + create unique_index(:shapes, [:name]) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index e7faf141..c28ba3e2 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 11.14 --- Dumped by pg_dump version 14.1 +-- Dumped from database version 14.12 (Homebrew) +-- Dumped by pg_dump version 14.12 (Homebrew) SET statement_timeout = 0; SET lock_timeout = 0; @@ -33,6 +33,8 @@ CREATE TYPE public.day_name AS ENUM ( SET default_tablespace = ''; +SET default_table_access_method = heap; + -- -- Name: adjustments; Type: TABLE; Schema: public; Owner: - -- @@ -337,6 +339,37 @@ CREATE TABLE public.schema_migrations ( ); +-- +-- Name: shapes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.shapes ( + id bigint NOT NULL, + name character varying(255), + inserted_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + +-- +-- Name: shapes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.shapes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: shapes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.shapes_id_seq OWNED BY public.shapes.id; + + -- -- Name: adjustments id; Type: DEFAULT; Schema: public; Owner: - -- @@ -400,6 +433,13 @@ ALTER TABLE ONLY public.disruption_trip_short_names ALTER COLUMN id SET DEFAULT ALTER TABLE ONLY public.disruptions ALTER COLUMN id SET DEFAULT nextval('public.disruptions_id_seq1'::regclass); +-- +-- Name: shapes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.shapes ALTER COLUMN id SET DEFAULT nextval('public.shapes_id_seq'::regclass); + + -- -- Name: adjustments adjustments_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -480,6 +520,14 @@ ALTER TABLE ONLY public.schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +-- +-- Name: shapes shapes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.shapes + ADD CONSTRAINT shapes_pkey PRIMARY KEY (id); + + -- -- Name: adjustments_source_label_index; Type: INDEX; Schema: public; Owner: - -- @@ -543,6 +591,13 @@ CREATE INDEX disruption_notes_disruption_id_index ON public.disruption_notes USI CREATE INDEX disruption_trip_short_names_disruption_id_index ON public.disruption_trip_short_names USING btree (disruption_revision_id); +-- +-- Name: shapes_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX shapes_name_index ON public.shapes USING btree (name); + + -- -- Name: unique_disruption_weekday; Type: INDEX; Schema: public; Owner: - -- @@ -637,3 +692,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20210924180538); INSERT INTO public."schema_migrations" (version) VALUES (20211209185029); INSERT INTO public."schema_migrations" (version) VALUES (20220105203850); INSERT INTO public."schema_migrations" (version) VALUES (20240207224211); +INSERT INTO public."schema_migrations" (version) VALUES (20240605185923); diff --git a/assets/fonts/Inter-Black.woff b/priv/static/fonts/Inter-Black.woff similarity index 100% rename from assets/fonts/Inter-Black.woff rename to priv/static/fonts/Inter-Black.woff diff --git a/assets/fonts/Inter-Bold.woff b/priv/static/fonts/Inter-Bold.woff similarity index 100% rename from assets/fonts/Inter-Bold.woff rename to priv/static/fonts/Inter-Bold.woff diff --git a/assets/fonts/Inter-Medium.woff b/priv/static/fonts/Inter-Medium.woff similarity index 100% rename from assets/fonts/Inter-Medium.woff rename to priv/static/fonts/Inter-Medium.woff diff --git a/assets/fonts/Inter-Regular.woff b/priv/static/fonts/Inter-Regular.woff similarity index 100% rename from assets/fonts/Inter-Regular.woff rename to priv/static/fonts/Inter-Regular.woff diff --git a/assets/static/images/checkbox-empty.png b/priv/static/images/checkbox-empty.png similarity index 100% rename from assets/static/images/checkbox-empty.png rename to priv/static/images/checkbox-empty.png diff --git a/assets/static/images/checkbox-filled.png b/priv/static/images/checkbox-filled.png similarity index 100% rename from assets/static/images/checkbox-filled.png rename to priv/static/images/checkbox-filled.png diff --git a/priv/static/images/icon-blue-line-small-5927f3a1356a24480390fc0a4fedb697.svg b/priv/static/images/icon-blue-line-small-5927f3a1356a24480390fc0a4fedb697.svg new file mode 100644 index 00000000..e17356c1 --- /dev/null +++ b/priv/static/images/icon-blue-line-small-5927f3a1356a24480390fc0a4fedb697.svg @@ -0,0 +1,7 @@ + + + blue line + + + + diff --git a/priv/static/images/icon-blue-line-small-5927f3a1356a24480390fc0a4fedb697.svg.gz b/priv/static/images/icon-blue-line-small-5927f3a1356a24480390fc0a4fedb697.svg.gz new file mode 100644 index 00000000..06d27318 Binary files /dev/null and b/priv/static/images/icon-blue-line-small-5927f3a1356a24480390fc0a4fedb697.svg.gz differ diff --git a/priv/static/images/icon-blue-line-small.svg.gz b/priv/static/images/icon-blue-line-small.svg.gz new file mode 100644 index 00000000..06d27318 Binary files /dev/null and b/priv/static/images/icon-blue-line-small.svg.gz differ diff --git a/priv/static/images/icon-green-line-b-small-28f4efe18e70efad0c0154c08854b0d6.svg b/priv/static/images/icon-green-line-b-small-28f4efe18e70efad0c0154c08854b0d6.svg new file mode 100644 index 00000000..01c174df --- /dev/null +++ b/priv/static/images/icon-green-line-b-small-28f4efe18e70efad0c0154c08854b0d6.svg @@ -0,0 +1,7 @@ + + + green line B + + + + diff --git a/priv/static/images/icon-green-line-b-small-28f4efe18e70efad0c0154c08854b0d6.svg.gz b/priv/static/images/icon-green-line-b-small-28f4efe18e70efad0c0154c08854b0d6.svg.gz new file mode 100644 index 00000000..c6482b1c Binary files /dev/null and b/priv/static/images/icon-green-line-b-small-28f4efe18e70efad0c0154c08854b0d6.svg.gz differ diff --git a/priv/static/images/icon-green-line-b-small.svg.gz b/priv/static/images/icon-green-line-b-small.svg.gz new file mode 100644 index 00000000..c6482b1c Binary files /dev/null and b/priv/static/images/icon-green-line-b-small.svg.gz differ diff --git a/priv/static/images/icon-green-line-c-small-739dbf5cf8980e91871b4cd16f22e7f6.svg b/priv/static/images/icon-green-line-c-small-739dbf5cf8980e91871b4cd16f22e7f6.svg new file mode 100644 index 00000000..0ede7c8f --- /dev/null +++ b/priv/static/images/icon-green-line-c-small-739dbf5cf8980e91871b4cd16f22e7f6.svg @@ -0,0 +1,7 @@ + + + green line C + + + + diff --git a/priv/static/images/icon-green-line-c-small-739dbf5cf8980e91871b4cd16f22e7f6.svg.gz b/priv/static/images/icon-green-line-c-small-739dbf5cf8980e91871b4cd16f22e7f6.svg.gz new file mode 100644 index 00000000..cb4787cf Binary files /dev/null and b/priv/static/images/icon-green-line-c-small-739dbf5cf8980e91871b4cd16f22e7f6.svg.gz differ diff --git a/priv/static/images/icon-green-line-c-small.svg.gz b/priv/static/images/icon-green-line-c-small.svg.gz new file mode 100644 index 00000000..cb4787cf Binary files /dev/null and b/priv/static/images/icon-green-line-c-small.svg.gz differ diff --git a/priv/static/images/icon-green-line-d-small-aa2e6dc17c0eef5659e475a8cea91e7d.svg b/priv/static/images/icon-green-line-d-small-aa2e6dc17c0eef5659e475a8cea91e7d.svg new file mode 100644 index 00000000..3468f24e --- /dev/null +++ b/priv/static/images/icon-green-line-d-small-aa2e6dc17c0eef5659e475a8cea91e7d.svg @@ -0,0 +1,7 @@ + + + green line D + + + + diff --git a/priv/static/images/icon-green-line-d-small-aa2e6dc17c0eef5659e475a8cea91e7d.svg.gz b/priv/static/images/icon-green-line-d-small-aa2e6dc17c0eef5659e475a8cea91e7d.svg.gz new file mode 100644 index 00000000..3110684f Binary files /dev/null and b/priv/static/images/icon-green-line-d-small-aa2e6dc17c0eef5659e475a8cea91e7d.svg.gz differ diff --git a/priv/static/images/icon-green-line-d-small.svg.gz b/priv/static/images/icon-green-line-d-small.svg.gz new file mode 100644 index 00000000..3110684f Binary files /dev/null and b/priv/static/images/icon-green-line-d-small.svg.gz differ diff --git a/priv/static/images/icon-green-line-e-small-dede36b99b451faa097194789cb1eeb2.svg b/priv/static/images/icon-green-line-e-small-dede36b99b451faa097194789cb1eeb2.svg new file mode 100644 index 00000000..e85d5575 --- /dev/null +++ b/priv/static/images/icon-green-line-e-small-dede36b99b451faa097194789cb1eeb2.svg @@ -0,0 +1,7 @@ + + + green line E + + + + diff --git a/priv/static/images/icon-green-line-e-small-dede36b99b451faa097194789cb1eeb2.svg.gz b/priv/static/images/icon-green-line-e-small-dede36b99b451faa097194789cb1eeb2.svg.gz new file mode 100644 index 00000000..e69fe318 Binary files /dev/null and b/priv/static/images/icon-green-line-e-small-dede36b99b451faa097194789cb1eeb2.svg.gz differ diff --git a/priv/static/images/icon-green-line-e-small.svg.gz b/priv/static/images/icon-green-line-e-small.svg.gz new file mode 100644 index 00000000..e69fe318 Binary files /dev/null and b/priv/static/images/icon-green-line-e-small.svg.gz differ diff --git a/priv/static/images/icon-green-line-small-ca0e3538d8db115aa6b9f5f028484cc5.svg b/priv/static/images/icon-green-line-small-ca0e3538d8db115aa6b9f5f028484cc5.svg new file mode 100644 index 00000000..5c8b1b34 --- /dev/null +++ b/priv/static/images/icon-green-line-small-ca0e3538d8db115aa6b9f5f028484cc5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/static/images/icon-green-line-small-ca0e3538d8db115aa6b9f5f028484cc5.svg.gz b/priv/static/images/icon-green-line-small-ca0e3538d8db115aa6b9f5f028484cc5.svg.gz new file mode 100644 index 00000000..248732c8 Binary files /dev/null and b/priv/static/images/icon-green-line-small-ca0e3538d8db115aa6b9f5f028484cc5.svg.gz differ diff --git a/priv/static/images/icon-green-line-small.svg.gz b/priv/static/images/icon-green-line-small.svg.gz new file mode 100644 index 00000000..248732c8 Binary files /dev/null and b/priv/static/images/icon-green-line-small.svg.gz differ diff --git a/priv/static/images/icon-mattapan-line-small-995fa04733fbf96671e40d4700db4028.svg b/priv/static/images/icon-mattapan-line-small-995fa04733fbf96671e40d4700db4028.svg new file mode 100644 index 00000000..fe749636 --- /dev/null +++ b/priv/static/images/icon-mattapan-line-small-995fa04733fbf96671e40d4700db4028.svg @@ -0,0 +1,7 @@ + + + mattapan line + + + + diff --git a/priv/static/images/icon-mattapan-line-small-995fa04733fbf96671e40d4700db4028.svg.gz b/priv/static/images/icon-mattapan-line-small-995fa04733fbf96671e40d4700db4028.svg.gz new file mode 100644 index 00000000..e17710f7 Binary files /dev/null and b/priv/static/images/icon-mattapan-line-small-995fa04733fbf96671e40d4700db4028.svg.gz differ diff --git a/priv/static/images/icon-mattapan-line-small.svg.gz b/priv/static/images/icon-mattapan-line-small.svg.gz new file mode 100644 index 00000000..e17710f7 Binary files /dev/null and b/priv/static/images/icon-mattapan-line-small.svg.gz differ diff --git a/priv/static/images/icon-mode-bus-small-24ec463ca1d98809a6ecd537f94139d7.svg b/priv/static/images/icon-mode-bus-small-24ec463ca1d98809a6ecd537f94139d7.svg new file mode 100644 index 00000000..d724649b --- /dev/null +++ b/priv/static/images/icon-mode-bus-small-24ec463ca1d98809a6ecd537f94139d7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/static/images/icon-mode-bus-small-24ec463ca1d98809a6ecd537f94139d7.svg.gz b/priv/static/images/icon-mode-bus-small-24ec463ca1d98809a6ecd537f94139d7.svg.gz new file mode 100644 index 00000000..4fc0063c Binary files /dev/null and b/priv/static/images/icon-mode-bus-small-24ec463ca1d98809a6ecd537f94139d7.svg.gz differ diff --git a/priv/static/images/icon-mode-bus-small.svg.gz b/priv/static/images/icon-mode-bus-small.svg.gz new file mode 100644 index 00000000..4fc0063c Binary files /dev/null and b/priv/static/images/icon-mode-bus-small.svg.gz differ diff --git a/priv/static/images/icon-mode-commuter-rail-small-4422f18823348d5a7377c5f156de893d.svg b/priv/static/images/icon-mode-commuter-rail-small-4422f18823348d5a7377c5f156de893d.svg new file mode 100644 index 00000000..e3113c72 --- /dev/null +++ b/priv/static/images/icon-mode-commuter-rail-small-4422f18823348d5a7377c5f156de893d.svg @@ -0,0 +1,11 @@ + + + commuter rail + + + + + + + + diff --git a/priv/static/images/icon-mode-commuter-rail-small-4422f18823348d5a7377c5f156de893d.svg.gz b/priv/static/images/icon-mode-commuter-rail-small-4422f18823348d5a7377c5f156de893d.svg.gz new file mode 100644 index 00000000..991f6d1c Binary files /dev/null and b/priv/static/images/icon-mode-commuter-rail-small-4422f18823348d5a7377c5f156de893d.svg.gz differ diff --git a/priv/static/images/icon-mode-commuter-rail-small.svg.gz b/priv/static/images/icon-mode-commuter-rail-small.svg.gz new file mode 100644 index 00000000..991f6d1c Binary files /dev/null and b/priv/static/images/icon-mode-commuter-rail-small.svg.gz differ diff --git a/priv/static/images/icon-mode-subway-small-31860b63cda9ee17cb66f05339cb5fe8.svg b/priv/static/images/icon-mode-subway-small-31860b63cda9ee17cb66f05339cb5fe8.svg new file mode 100644 index 00000000..fc373ff0 --- /dev/null +++ b/priv/static/images/icon-mode-subway-small-31860b63cda9ee17cb66f05339cb5fe8.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/static/images/icon-mode-subway-small-31860b63cda9ee17cb66f05339cb5fe8.svg.gz b/priv/static/images/icon-mode-subway-small-31860b63cda9ee17cb66f05339cb5fe8.svg.gz new file mode 100644 index 00000000..7527dd82 Binary files /dev/null and b/priv/static/images/icon-mode-subway-small-31860b63cda9ee17cb66f05339cb5fe8.svg.gz differ diff --git a/priv/static/images/icon-mode-subway-small.svg.gz b/priv/static/images/icon-mode-subway-small.svg.gz new file mode 100644 index 00000000..7527dd82 Binary files /dev/null and b/priv/static/images/icon-mode-subway-small.svg.gz differ diff --git a/priv/static/images/icon-orange-line-small-04c308713f052f0db6c9765305366541.svg b/priv/static/images/icon-orange-line-small-04c308713f052f0db6c9765305366541.svg new file mode 100644 index 00000000..0011378d --- /dev/null +++ b/priv/static/images/icon-orange-line-small-04c308713f052f0db6c9765305366541.svg @@ -0,0 +1,10 @@ + + + orange line + + + + + + + diff --git a/priv/static/images/icon-orange-line-small-04c308713f052f0db6c9765305366541.svg.gz b/priv/static/images/icon-orange-line-small-04c308713f052f0db6c9765305366541.svg.gz new file mode 100644 index 00000000..c0eeac69 Binary files /dev/null and b/priv/static/images/icon-orange-line-small-04c308713f052f0db6c9765305366541.svg.gz differ diff --git a/priv/static/images/icon-orange-line-small.svg.gz b/priv/static/images/icon-orange-line-small.svg.gz new file mode 100644 index 00000000..c0eeac69 Binary files /dev/null and b/priv/static/images/icon-orange-line-small.svg.gz differ diff --git a/priv/static/images/icon-red-line-small-3f542b6fae94745db7cc0ff530cb9390.svg b/priv/static/images/icon-red-line-small-3f542b6fae94745db7cc0ff530cb9390.svg new file mode 100644 index 00000000..b2e2f0df --- /dev/null +++ b/priv/static/images/icon-red-line-small-3f542b6fae94745db7cc0ff530cb9390.svg @@ -0,0 +1,10 @@ + + + red line + + + + + + + diff --git a/priv/static/images/icon-red-line-small-3f542b6fae94745db7cc0ff530cb9390.svg.gz b/priv/static/images/icon-red-line-small-3f542b6fae94745db7cc0ff530cb9390.svg.gz new file mode 100644 index 00000000..7bc45ef6 Binary files /dev/null and b/priv/static/images/icon-red-line-small-3f542b6fae94745db7cc0ff530cb9390.svg.gz differ diff --git a/priv/static/images/icon-red-line-small.svg.gz b/priv/static/images/icon-red-line-small.svg.gz new file mode 100644 index 00000000..7bc45ef6 Binary files /dev/null and b/priv/static/images/icon-red-line-small.svg.gz differ diff --git a/priv/static/images/icon-silver-line-small-fff06f2cb4d9c5d85b9fd155fd4f2fa9.svg b/priv/static/images/icon-silver-line-small-fff06f2cb4d9c5d85b9fd155fd4f2fa9.svg new file mode 100644 index 00000000..c015028d --- /dev/null +++ b/priv/static/images/icon-silver-line-small-fff06f2cb4d9c5d85b9fd155fd4f2fa9.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/static/images/icon-silver-line-small-fff06f2cb4d9c5d85b9fd155fd4f2fa9.svg.gz b/priv/static/images/icon-silver-line-small-fff06f2cb4d9c5d85b9fd155fd4f2fa9.svg.gz new file mode 100644 index 00000000..ee147d1a Binary files /dev/null and b/priv/static/images/icon-silver-line-small-fff06f2cb4d9c5d85b9fd155fd4f2fa9.svg.gz differ diff --git a/priv/static/images/icon-silver-line-small.svg.gz b/priv/static/images/icon-silver-line-small.svg.gz new file mode 100644 index 00000000..ee147d1a Binary files /dev/null and b/priv/static/images/icon-silver-line-small.svg.gz differ diff --git a/priv/static/images/logo-5c8510ba5230b9ab92430432aba36aa9.svg b/priv/static/images/logo-5c8510ba5230b9ab92430432aba36aa9.svg new file mode 100644 index 00000000..00d7d288 --- /dev/null +++ b/priv/static/images/logo-5c8510ba5230b9ab92430432aba36aa9.svg @@ -0,0 +1 @@ + diff --git a/priv/static/images/logo-5c8510ba5230b9ab92430432aba36aa9.svg.gz b/priv/static/images/logo-5c8510ba5230b9ab92430432aba36aa9.svg.gz new file mode 100644 index 00000000..0672244f Binary files /dev/null and b/priv/static/images/logo-5c8510ba5230b9ab92430432aba36aa9.svg.gz differ diff --git a/priv/static/images/logo.svg.gz b/priv/static/images/logo.svg.gz new file mode 100644 index 00000000..0672244f Binary files /dev/null and b/priv/static/images/logo.svg.gz differ diff --git a/test/arrow/shuttle_test.exs b/test/arrow/shuttle_test.exs new file mode 100644 index 00000000..cc974208 --- /dev/null +++ b/test/arrow/shuttle_test.exs @@ -0,0 +1,59 @@ +defmodule Arrow.ShuttleTest do + use Arrow.DataCase + + alias Arrow.Shuttle + + describe "shapes" do + alias Arrow.Shuttle.Shape + + import Arrow.ShuttleFixtures + + @invalid_attrs %{name: nil} + + test "list_shapes/0 returns all shapes" do + shape = shape_fixture() + assert Shuttle.list_shapes() == [shape] + end + + test "get_shape!/1 returns the shape with given id" do + shape = shape_fixture() + assert Shuttle.get_shape!(shape.id) == shape + end + + test "create_shape/1 with valid data creates a shape" do + valid_attrs = %{name: "some name"} + + assert {:ok, %Shape{} = shape} = Shuttle.create_shape(valid_attrs) + assert shape.name == "some name" + end + + test "create_shape/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Shuttle.create_shape(@invalid_attrs) + end + + test "update_shape/2 with valid data updates the shape" do + shape = shape_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %Shape{} = shape} = Shuttle.update_shape(shape, update_attrs) + assert shape.name == "some updated name" + end + + test "update_shape/2 with invalid data returns error changeset" do + shape = shape_fixture() + assert {:error, %Ecto.Changeset{}} = Shuttle.update_shape(shape, @invalid_attrs) + assert shape == Shuttle.get_shape!(shape.id) + end + + test "delete_shape/1 deletes the shape" do + shape = shape_fixture() + assert {:ok, %Shape{}} = Shuttle.delete_shape(shape) + assert_raise Ecto.NoResultsError, fn -> Shuttle.get_shape!(shape.id) end + end + + test "change_shape/1 returns a shape changeset" do + shape = shape_fixture() + assert %Ecto.Changeset{} = Shuttle.change_shape(shape) + end + end +end diff --git a/test/arrow_web/controllers/shape_controller_test.exs b/test/arrow_web/controllers/shape_controller_test.exs new file mode 100644 index 00000000..9e0f2c92 --- /dev/null +++ b/test/arrow_web/controllers/shape_controller_test.exs @@ -0,0 +1,96 @@ +defmodule ArrowWeb.ShapeControllerTest do + use ArrowWeb.ConnCase, async: true + + import Arrow.ShuttleFixtures + + @create_attrs %{name: "some name", filename: %Plug.Upload{filename: "some filename"}} + @update_attrs %{name: "some updated name", filename: %Plug.Upload{filename: "some filename"}} + @invalid_attrs %{name: nil} + + describe "index" do + @tag :authenticated_admin + test "lists all shapes", %{conn: conn} do + conn = get(conn, ~p"/shapes") + assert html_response(conn, 200) =~ "Listing Shapes" + end + end + + describe "new shape" do + @tag :authenticated_admin + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/shapes/new") + assert html_response(conn, 200) =~ "New Shape" + end + end + + describe "create shape" do + @tag :authenticated_admin + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/shapes", shape: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/shapes/#{id}" + + conn = ArrowWeb.ConnCase.authenticated_admin() + conn = get(conn, ~p"/shapes/#{id}") + assert html_response(conn, 200) =~ "Shape #{id}" + end + + @tag :authenticated_admin + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/shapes", shape: @invalid_attrs) + assert html_response(conn, 200) =~ "New Shape" + end + end + + describe "edit shape" do + setup [:create_shape] + + @tag :authenticated_admin + test "renders form for editing chosen shape", %{conn: conn, shape: shape} do + conn = get(conn, ~p"/shapes/#{shape}/edit") + assert html_response(conn, 200) =~ "Edit Shape" + end + end + + describe "update shape" do + setup [:create_shape] + + @tag :authenticated_admin + test "redirects when data is valid", %{conn: conn, shape: shape} do + conn = put(conn, ~p"/shapes/#{shape}", shape: @update_attrs) + assert redirected_to(conn) == ~p"/shapes/#{shape}" + + conn = ArrowWeb.ConnCase.authenticated_admin() + conn = get(conn, ~p"/shapes/#{shape}") + assert html_response(conn, 200) =~ "some updated name" + end + + @tag :authenticated_admin + test "renders errors when data is invalid", %{conn: conn, shape: shape} do + conn = put(conn, ~p"/shapes/#{shape}", shape: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Shape" + end + end + + describe "delete shape" do + setup [:create_shape] + + @tag :authenticated_admin + test "deletes chosen shape", %{conn: conn, shape: shape} do + conn = delete(conn, ~p"/shapes/#{shape}") + assert redirected_to(conn) == ~p"/shapes" + + conn = ArrowWeb.ConnCase.authenticated_admin() + + assert_error_sent 404, fn -> + get(conn, ~p"/shapes/#{shape}") + end + end + end + + defp create_shape(_) do + shape = shape_fixture() + %{shape: shape} + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index dfc62568..7e67beb3 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -20,13 +20,15 @@ defmodule ArrowWeb.ConnCase do using do quote do + # The default endpoint for testing + @endpoint ArrowWeb.Endpoint + + use ArrowWeb, :verified_routes + # Import conveniences for testing with connections import Plug.Conn import Phoenix.ConnTest alias ArrowWeb.Router.Helpers, as: Routes - - # The default endpoint for testing - @endpoint ArrowWeb.Endpoint end end @@ -42,7 +44,7 @@ defmodule ArrowWeb.ConnCase do {:ok, conn: build_conn("test_user", ["read-only"])} tags[:authenticated_admin] -> - {:ok, conn: build_conn("test_user", ["admin"])} + {:ok, conn: authenticated_admin()} tags[:authenticated_empty] -> {:ok, conn: build_conn("test_user", [])} @@ -62,4 +64,6 @@ defmodule ArrowWeb.ConnCase do |> init_test_session(%{}) |> Guardian.Plug.sign_in(ArrowWeb.AuthManager, user, %{roles: roles}) end + + def authenticated_admin, do: build_conn("test_user", ["admin"]) end diff --git a/test/support/fixtures/shuttle_fixtures.ex b/test/support/fixtures/shuttle_fixtures.ex new file mode 100644 index 00000000..f627401b --- /dev/null +++ b/test/support/fixtures/shuttle_fixtures.ex @@ -0,0 +1,25 @@ +defmodule Arrow.ShuttleFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Arrow.Shuttle` context. + """ + + @doc """ + Generate a unique shape name. + """ + def unique_shape_name, do: "some name#{System.unique_integer([:positive])}" + + @doc """ + Generate a shape. + """ + def shape_fixture(attrs \\ %{}) do + {:ok, shape} = + attrs + |> Enum.into(%{ + name: unique_shape_name() + }) + |> Arrow.Shuttle.create_shape() + + shape + end +end