diff --git a/lib/trip_planner/input_form.ex b/lib/trip_planner/input_form.ex new file mode 100644 index 0000000000..e8b0df0c27 --- /dev/null +++ b/lib/trip_planner/input_form.ex @@ -0,0 +1,111 @@ +defmodule TripPlanner.InputForm do + @moduledoc """ + Describes the inputs users can fill to request trip plans. + + At minimum, this requires two locations. + """ + + use TypedEctoSchema + import Ecto.Changeset + + @error_messages %{ + from: "Please specify an origin location.", + to: "Please add a destination.", + from_to_same: "Please select a destination at a different location from the origin." + } + + @primary_key false + typed_embedded_schema do + embeds_one(:from, __MODULE__.Location) + embeds_one(:to, __MODULE__.Location) + end + + def changeset(params \\ %{}) do + changeset(%__MODULE__{}, params) + end + + def changeset(form, params) do + form + |> cast(params, []) + |> cast_embed(:from, required: true) + |> cast_embed(:to, required: true) + end + + def validate_params(params) do + changes = + params + |> changeset() + |> update_change(:from, &update_location_change/1) + |> update_change(:to, &update_location_change/1) + |> validate_required(:from, message: error_message(:from)) + |> validate_required(:to, message: error_message(:to)) + |> validate_same_locations() + + if changes.errors == [] do + changes + else + %Ecto.Changeset{changes | action: :update} + end + end + + # make the parent field blank if the location isn't valid + defp update_location_change(%Ecto.Changeset{valid?: false, errors: [_ | _]}), do: nil + defp update_location_change(changeset), do: changeset + + defp validate_same_locations(changeset) do + with from_change when not is_nil(from_change) <- get_change(changeset, :from), + to_change when to_change === from_change <- get_change(changeset, :to) do + add_error( + changeset, + :to, + error_message(:from_to_same) + ) + else + _ -> + changeset + end + end + + def error_message(key), do: @error_messages[key] + + defmodule Location do + @moduledoc """ + Represents a location for requesting a trip plan. At minimum, coordinates + are required. A stop_id is expected to be associated with an MBTA GTFS stop. + If a name is not provided, one can be created based on the coordinates. + """ + + use TypedEctoSchema + + @primary_key false + typed_embedded_schema do + field(:latitude, :float, null: false) + field(:longitude, :float, null: false) + field(:name, :string) + field(:stop_id, :string) :: Stops.Stop.id_t() + end + + def changeset(form \\ %__MODULE__{}, params \\ %{}) do + form + |> cast(params, [:latitude, :longitude, :name, :stop_id]) + |> validate_required([:latitude, :longitude]) + |> add_default_name() + end + + defp add_default_name(changeset) do + if is_nil(changeset.data.name) and + (changed?(changeset, :latitude) or changed?(changeset, :longitude)) do + {_, latitude} = fetch_field(changeset, :latitude) + {_, longitude} = fetch_field(changeset, :longitude) + + put_change( + changeset, + :name, + "#{latitude}, #{longitude}" + ) + else + changeset + end + end + end +end diff --git a/mix.exs b/mix.exs index 50cf94a1c7..be9186a4d2 100644 --- a/mix.exs +++ b/mix.exs @@ -77,6 +77,7 @@ defmodule DotCom.Mixfile do {:decorator, "1.4.0"}, {:dialyxir, "1.4.3", [only: [:test, :dev], runtime: false]}, {:diskusage_logger, "0.2.0"}, + {:ecto, "3.12.1"}, {:eflame, "1.0.1", only: :dev}, {:ehmon, [github: "mbta/ehmon", only: :prod]}, {:ex_aws, "2.5.4"}, @@ -106,6 +107,7 @@ defmodule DotCom.Mixfile do {:parallel_stream, "1.1.0"}, # latest version 1.7.14 {:phoenix, "~> 1.7"}, + {:phoenix_ecto, "4.6.2"}, # latest version 4.1.1; cannot upgrade because we use Phoenix.HTML {:phoenix_html, "3.3.3"}, {:phoenix_live_dashboard, "0.8.4"}, @@ -143,6 +145,7 @@ defmodule DotCom.Mixfile do {:telemetry_poller, "1.1.0"}, {:telemetry_test, "0.1.2", only: [:test]}, {:timex, "3.7.11"}, + {:typed_ecto_schema, "0.4.1"}, {:unrooted_polytree, "0.1.1"}, {:uuid, "1.1.8"}, {:wallaby, "0.30.9", [runtime: false, only: [:test, :dev]]}, diff --git a/mix.lock b/mix.lock index c43e3181e9..03c8296d41 100644 --- a/mix.lock +++ b/mix.lock @@ -10,10 +10,12 @@ "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "csv": {:hex, :csv, "3.2.1", "6d401f1ed33acb2627682a9ab6021e96d33ca6c1c6bccc243d8f7e2197d032f5", [:mix], [], "hexpm", "8f55a0524923ae49e97ff2642122a2ce7c61e159e7fe1184670b2ce847aee6c8"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "diskusage_logger": {:hex, :diskusage_logger, "0.2.0", "04fc48b538fe4de43153542a71ea94f623d54707d85844123baacfceedf625c3", [:mix], [], "hexpm", "e3f2aed1b0fc4590931c089a6453a4c4eb4c945912aa97bcabcc0cff7851f34d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "ecto": {:hex, :ecto, "3.12.1", "626765f7066589de6fa09e0876a253ff60c3d00870dd3a1cd696e2ba67bfceea", [: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", "df0045ab9d87be947228e05a8d153f3e06e0d05ab10c3b3cc557d2f7243d1940"}, "eflame": {:hex, :eflame, "1.0.1", "0664d287e39eef3c413749254b3af5f4f8b00be71c1af67d325331c4890be0fc", [:mix], [], "hexpm", "e0b08854a66f9013129de0b008488f3411ae9b69b902187837f994d7a99cf04e"}, "ehmon": {:git, "https://github.com/mbta/ehmon.git", "1fb603262bd02d74a16183bd8f344dcace9d7561", []}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, @@ -64,6 +66,7 @@ "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [: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", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, @@ -100,6 +103,7 @@ "telemetry_test": {:hex, :telemetry_test, "0.1.2", "122d927567c563cf57773105fa8104ae4299718ec2cbdddcf6776562c7488072", [:mix], [{:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7bd41a49ecfd33ecd82d2c7edae19a5736f0d2150206d0ee290dcf3885d0e14d"}, "tesla": {:hex, :tesla, "1.11.2", "24707ac48b52f72f88fc05d242b1c59a85d1ee6f16f19c312d7d3419665c9cd5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c549cd03aec6a7196a641689dd378b799e635eb393f689b4bd756f750c7a4014"}, "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, + "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.4.1", "a373ca6f693f4de84cde474a67467a9cb9051a8a7f3f615f1e23dc74b75237fa", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "85c6962f79d35bf543dd5659c6adc340fd2480cacc6f25d2cc2933ea6e8fcb3b"}, "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, diff --git a/test/trip_planner/input_form_test.exs b/test/trip_planner/input_form_test.exs new file mode 100644 index 0000000000..8d8a17af7b --- /dev/null +++ b/test/trip_planner/input_form_test.exs @@ -0,0 +1,87 @@ +defmodule TripPlanner.InputFormTest do + use ExUnit.Case, async: true + + alias TripPlanner.InputForm + + @from_params %{ + "latitude" => "#{Faker.Address.latitude()}", + "longitude" => "#{Faker.Address.longitude()}" + } + @to_params %{ + "latitude" => "#{Faker.Address.latitude()}", + "longitude" => "#{Faker.Address.longitude()}" + } + @params %{ + "from" => @from_params, + "to" => @to_params + } + + test "from & to fields are required" do + changeset = InputForm.changeset(%{}) + assert {_, [validation: :required]} = changeset.errors[:from] + assert {_, [validation: :required]} = changeset.errors[:to] + end + + describe "validate_params/1" do + test "validates to & from" do + changeset = InputForm.validate_params(@params) + assert changeset.valid? + end + + test "adds from & to errors" do + changeset = + InputForm.validate_params(%{ + "from" => %{ + "latitude" => "", + "longitude" => "", + "name" => "", + "stop_id" => "" + }, + "to" => %{ + "latitude" => "", + "longitude" => "", + "name" => "", + "stop_id" => "" + } + }) + + refute changeset.valid? + assert changeset.errors[:to] + assert changeset.errors[:from] + end + + test "adds error if from & to are the same" do + changeset = + InputForm.validate_params(%{ + "from" => @from_params, + "to" => @from_params + }) + + refute changeset.valid? + + expected_error = InputForm.error_message(:from_to_same) + assert {^expected_error, _} = changeset.errors[:to] + end + end + + describe "Location" do + test "longitude & latitude fields are required" do + changeset = InputForm.Location.changeset() + assert {_, [validation: :required]} = changeset.errors[:latitude] + assert {_, [validation: :required]} = changeset.errors[:longitude] + end + + test "adds a name if none provided" do + lat = Faker.Address.latitude() + lon = Faker.Address.longitude() + + changeset = + InputForm.Location.changeset(%InputForm.Location{}, %{ + "latitude" => "#{lat}", + "longitude" => "#{lon}" + }) + + assert changeset.changes.name == "#{lat}, #{lon}" + end + end +end