From 14b48f01187d4c3af87a95f664f8be4f7a9b902f Mon Sep 17 00:00:00 2001 From: Anthony Shull Date: Mon, 16 Dec 2024 15:22:22 -0600 Subject: [PATCH] Improve state management (#2266) --- assets/css/_autocomplete-theme.scss | 8 +- lib/dotcom/trip_plan/anti_corruption_layer.ex | 5 +- lib/dotcom/trip_plan/input_form.ex | 33 +- .../input_form.ex} | 141 +----- .../trip_planner/itinerary_detail.ex | 35 +- .../trip_planner/itinerary_summary.ex | 3 +- .../components/trip_planner/results.ex | 87 ++++ .../trip_planner/results_summary.ex | 52 +++ .../trip_planner_results_section.ex | 156 ------- lib/dotcom_web/live/trip_planner.ex | 420 +++++++++++++----- test/dotcom/trip_plan/input_form_test.exs | 31 +- .../trip_planner/trip_planner_form_test.exs | 23 - 12 files changed, 497 insertions(+), 497 deletions(-) rename lib/dotcom_web/components/{live_components/trip_planner_form.ex => trip_planner/input_form.ex} (51%) create mode 100644 lib/dotcom_web/components/trip_planner/results.ex create mode 100644 lib/dotcom_web/components/trip_planner/results_summary.ex delete mode 100644 lib/dotcom_web/components/trip_planner/trip_planner_results_section.ex delete mode 100644 test/dotcom_web/components/live_components/trip_planner/trip_planner_form_test.exs diff --git a/assets/css/_autocomplete-theme.scss b/assets/css/_autocomplete-theme.scss index 8aaa7331fa..6d77f0f6d5 100644 --- a/assets/css/_autocomplete-theme.scss +++ b/assets/css/_autocomplete-theme.scss @@ -203,8 +203,8 @@ } } -#trip-planner-form--from, -#trip-planner-form--to { +#trip-planner-input-form--from, +#trip-planner-input-form--to { .aa-InputWrapperPrefix { order: unset; } @@ -214,10 +214,10 @@ } } -#trip-planner-form--from .aa-SubmitButton { +#trip-planner-input-form--from .aa-SubmitButton { @include fa-icon-solid($fa-var-a); } -#trip-planner-form--to .aa-SubmitButton { +#trip-planner-input-form--to .aa-SubmitButton { @include fa-icon-solid($fa-var-b); } diff --git a/lib/dotcom/trip_plan/anti_corruption_layer.ex b/lib/dotcom/trip_plan/anti_corruption_layer.ex index 24fa84ebdc..66df465166 100644 --- a/lib/dotcom/trip_plan/anti_corruption_layer.ex +++ b/lib/dotcom/trip_plan/anti_corruption_layer.ex @@ -40,9 +40,12 @@ defmodule Dotcom.TripPlan.AntiCorruptionLayer do modes |> Enum.reduce(default_modes, fn {key, value}, acc -> - Map.put(acc, String.upcase(key), value) + Map.put(acc, convert_mode(key), value) end) end defp convert_modes(_), do: Dotcom.TripPlan.InputForm.initial_modes() + + defp convert_mode("commuter_rail"), do: "RAIL" + defp convert_mode(mode), do: String.upcase(mode) end diff --git a/lib/dotcom/trip_plan/input_form.ex b/lib/dotcom/trip_plan/input_form.ex index 6456da979e..b634f2f0ad 100644 --- a/lib/dotcom/trip_plan/input_form.ex +++ b/lib/dotcom/trip_plan/input_form.ex @@ -6,6 +6,7 @@ defmodule Dotcom.TripPlan.InputForm do """ use TypedEctoSchema + import Ecto.Changeset alias OpenTripPlannerClient.PlanParams @@ -60,15 +61,10 @@ defmodule Dotcom.TripPlan.InputForm do def changeset(form, params) do form - |> cast(params, [:datetime_type, :datetime, :wheelchair]) + |> cast(params, [:datetime, :datetime_type, :wheelchair]) |> cast_embed(:from, required: true) |> cast_embed(:to, required: true) |> cast_embed(:modes, required: true) - end - - def validate_params(params) do - params - |> changeset() |> update_change(:from, &update_location_change/1) |> update_change(:to, &update_location_change/1) |> validate_required(:from, message: error_message(:from)) @@ -77,7 +73,6 @@ defmodule Dotcom.TripPlan.InputForm do |> validate_required([:datetime_type, :wheelchair]) |> validate_same_locations() |> validate_chosen_datetime() - |> validate_modes() end # make the parent field blank if the location isn't valid @@ -98,16 +93,6 @@ defmodule Dotcom.TripPlan.InputForm do end end - defp validate_modes(changeset) do - case get_change(changeset, :modes) do - %Ecto.Changeset{valid?: false} -> - add_error(changeset, :modes, error_message(:modes)) - - _ -> - changeset - end - end - defp validate_chosen_datetime(changeset) do case get_field(changeset, :datetime_type) do "now" -> @@ -191,10 +176,10 @@ defmodule Dotcom.TripPlan.InputForm do @primary_key false typed_embedded_schema do - field(:RAIL, :boolean, default: true) - field(:SUBWAY, :boolean, default: true) - field(:BUS, :boolean, default: true) - field(:FERRY, :boolean, default: true) + field(:RAIL, :boolean, default: false) + field(:SUBWAY, :boolean, default: false) + field(:BUS, :boolean, default: false) + field(:FERRY, :boolean, default: false) end def fields, do: __MODULE__.__schema__(:fields) @@ -244,7 +229,9 @@ defmodule Dotcom.TripPlan.InputForm do |> selected_modes() end - def selected_modes([]), do: "No transit modes selected" + def selected_modes(%{RAIL: true, SUBWAY: true, BUS: true, FERRY: true}), do: "All modes" + + def selected_modes(%{}), do: "Walking directions only" def selected_modes([mode]), do: mode_name(mode) <> " Only" def selected_modes(modes) do @@ -257,6 +244,8 @@ defmodule Dotcom.TripPlan.InputForm do end end + defp summarized_modes([]), do: "No transit modes selected" + defp summarized_modes([mode1, mode2]) do mode_name(mode1) <> " and " <> mode_name(mode2) end diff --git a/lib/dotcom_web/components/live_components/trip_planner_form.ex b/lib/dotcom_web/components/trip_planner/input_form.ex similarity index 51% rename from lib/dotcom_web/components/live_components/trip_planner_form.ex rename to lib/dotcom_web/components/trip_planner/input_form.ex index 0d37f2e01d..85ae047a05 100644 --- a/lib/dotcom_web/components/live_components/trip_planner_form.ex +++ b/lib/dotcom_web/components/trip_planner/input_form.ex @@ -1,8 +1,9 @@ -defmodule DotcomWeb.Components.LiveComponents.TripPlannerForm do +defmodule DotcomWeb.Components.TripPlanner.InputForm do @moduledoc """ A form to plan trips. """ - use DotcomWeb, :live_component + + use DotcomWeb, :component import MbtaMetro.Components.InputGroup import Phoenix.HTML.Form, only: [input_value: 2] @@ -10,61 +11,32 @@ defmodule DotcomWeb.Components.LiveComponents.TripPlannerForm do alias Dotcom.TripPlan.{InputForm, InputForm.Modes} alias MbtaMetro.Live.DatePicker - @impl true - def mount(socket) do - {:ok, socket} - end - - @impl true @doc """ If form values are passed in, we merge them with the defaults and submit the form. Otherwise, we just render the form. """ - def update(assigns, socket) do - form_defaults = get_form_defaults(assigns) - - defaults = %{ - form: %InputForm{} |> InputForm.changeset(form_defaults) |> to_form(), - location_keys: InputForm.Location.fields(), - show_datepicker: false - } - - new_socket = - socket - |> assign(assigns) - |> assign(defaults) - - if assigns[:form_values] do - save_form(form_defaults, new_socket) - end - - {:ok, new_socket} - end - - @impl true - def render(assigns) do + def input_form(assigns) do ~H"""
<.form :let={f} class="md:grid md:grid-cols-2 gap-x-8 gap-y-2" - id={@id} - for={@form} + id="trip-planner-input-form" + for={@changeset} method="get" - phx-submit="save_form" - phx-change="handle_change" - phx-target={@myself} + phx-change="input_form_change" + phx-submit="input_form_submit" >
<.algolia_autocomplete config_type="trip-planner" placeholder="Enter a location" - id={"#{@form_name}--#{field}"} + id={"trip-planner-input-form--#{field}"} > <.inputs_for :let={location_f} field={f[field]} skip_hidden={true}> - <.error_container :for={{msg, _} <- f[field].errors} :if={used_input?(f[field])}> + <.error_container :for={{msg, _} <- f[field].errors}> {msg} @@ -86,23 +58,18 @@ defmodule DotcomWeb.Components.LiveComponents.TripPlannerForm do options={[{"Now", "now"}, {"Leave at", "leave_at"}, {"Arrive by", "arrive_by"}]} type="radio-button" class="mb-0" - phx-change="toggle_datepicker" - phx-update="ignore" /> - <.error_container - :for={{msg, _} <- f[:datetime_type].errors} - :if={used_input?(f[:datetime_type])} - > + <.error_container :for={{msg, _} <- f[:datetime_type].errors}> {msg} <.live_component - :if={@show_datepicker} + :if={show_datepicker?(f)} module={DatePicker} config={datepicker_config()} field={f[:datetime]} id={:datepicker} /> - <.error_container :for={{msg, _} <- f[:datetime].errors} :if={used_input?(f[:datetime])}> + <.error_container :for={{msg, _} <- f[:datetime].errors}> {msg}
@@ -144,46 +111,6 @@ defmodule DotcomWeb.Components.LiveComponents.TripPlannerForm do """ end - @impl true - @doc """ - If the user selects "now" for the date and time, hide the datepicker. - This will destroy the flatpickr instance. - - If the user selects arrive by or leave at, then we show the datepicker and set the time to the nearest 5 minutes. - """ - def handle_event("toggle_datepicker", %{"input_form" => %{"datetime_type" => "now"}}, socket) do - new_socket = - socket - |> assign(show_datepicker: false) - |> push_event("set-datetime", %{datetime: nearest_5_minutes()}) - - {:noreply, new_socket} - end - - def handle_event("toggle_datepicker", _, socket) do - new_socket = - socket - |> assign(show_datepicker: true) - |> push_event("set-datetime", %{datetime: nearest_5_minutes()}) - - {:noreply, new_socket} - end - - def handle_event("handle_change", %{"input_form" => params}, socket) do - send(self(), {:changed_form, params}) - - form = - params - |> InputForm.validate_params() - |> Phoenix.Component.to_form() - - {:noreply, assign(socket, %{form: form})} - end - - def handle_event("save_form", %{"input_form" => params}, socket) do - {:noreply, save_form(params, socket)} - end - defp datepicker_config do %{ default_date: Timex.now("America/New_York"), @@ -193,43 +120,7 @@ defmodule DotcomWeb.Components.LiveComponents.TripPlannerForm do } end - defp get_form_defaults(assigns) do - assigns - |> Map.get(:form_values, %{ - "modes" => InputForm.initial_modes(), - "wheelchair" => true - }) - |> Map.merge(%{ - "datetime_type" => "now", - "datetime" => Timex.now("America/New_York") - }) - end - - defp nearest_5_minutes do - datetime = Timex.now("America/New_York") - minutes = datetime.minute - rounded_minutes = Float.ceil(minutes / 5) * 5 - added_minutes = Kernel.trunc(rounded_minutes - minutes) - - Timex.shift(datetime, minutes: added_minutes) - end - - defp save_form(params, socket) do - params - |> InputForm.validate_params() - |> Ecto.Changeset.apply_action(:update) - |> case do - {:ok, data} -> - send(self(), {:updated_form, data}) - - socket - - {:error, changeset} -> - form = - changeset - |> Phoenix.Component.to_form() - - assign(socket, %{form: form}) - end + defp show_datepicker?(f) do + input_value(f, :datetime_type) != "now" end end diff --git a/lib/dotcom_web/components/trip_planner/itinerary_detail.ex b/lib/dotcom_web/components/trip_planner/itinerary_detail.ex index a62e6eb92d..74657fd3bd 100644 --- a/lib/dotcom_web/components/trip_planner/itinerary_detail.ex +++ b/lib/dotcom_web/components/trip_planner/itinerary_detail.ex @@ -12,29 +12,26 @@ defmodule DotcomWeb.Components.TripPlanner.ItineraryDetail do alias Dotcom.TripPlan.LegToSegmentHelper - def itinerary_detail( - %{ - itineraries: itineraries, - selected_itinerary_detail_index: selected_itinerary_detail_index - } = assigns - ) do - assigns = - assign(assigns, :selected_itinerary, Enum.at(itineraries, selected_itinerary_detail_index)) + def itinerary_detail(assigns) do + itinerary_group = + Enum.at(assigns.results.itinerary_groups, assigns.results.itinerary_group_selection || 0) + + itinerary = Enum.at(itinerary_group.itineraries, assigns.results.itinerary_selection || 0) + + assigns = %{ + itinerary: itinerary, + itinerary_selection: assigns.results.itinerary_selection, + itineraries: itinerary_group.itineraries + } ~H"""
- <.depart_at_buttons - selected_itinerary_detail_index={@selected_itinerary_detail_index} - itineraries={@itineraries} - /> - <.specific_itinerary_detail itinerary={@selected_itinerary} /> + <.depart_at_buttons itineraries={@itineraries} itinerary_selection={@itinerary_selection} /> + <.specific_itinerary_detail itinerary={@itinerary} />
""" end - attr :itineraries, :list - attr :selected_itinerary_detail_index, :integer - defp depart_at_buttons(assigns) do ~H"""
1}> @@ -42,9 +39,9 @@ defmodule DotcomWeb.Components.TripPlanner.ItineraryDetail do
<.depart_at_button :for={{itinerary, index} <- Enum.with_index(@itineraries)} - active={@selected_itinerary_detail_index == index} - phx-click="set_itinerary_index" - phx-value-trip-index={index} + active={@itinerary_selection == index} + phx-click="select_itinerary" + phx-value-index={index} > {Timex.format!(itinerary.start, "%-I:%M%p", :strftime)} diff --git a/lib/dotcom_web/components/trip_planner/itinerary_summary.ex b/lib/dotcom_web/components/trip_planner/itinerary_summary.ex index 8d497b3354..ea69453aa5 100644 --- a/lib/dotcom_web/components/trip_planner/itinerary_summary.ex +++ b/lib/dotcom_web/components/trip_planner/itinerary_summary.ex @@ -2,9 +2,8 @@ defmodule DotcomWeb.Components.TripPlanner.ItinerarySummary do @moduledoc """ A component that renders the summary for a given itinerary """ - use DotcomWeb, :component - attr :summary, :map, doc: "ItineraryGroups.summary()", required: true + use DotcomWeb, :component def itinerary_summary(assigns) do ~H""" diff --git a/lib/dotcom_web/components/trip_planner/results.ex b/lib/dotcom_web/components/trip_planner/results.ex new file mode 100644 index 0000000000..446ce54886 --- /dev/null +++ b/lib/dotcom_web/components/trip_planner/results.ex @@ -0,0 +1,87 @@ +defmodule DotcomWeb.Components.TripPlanner.Results do + @moduledoc """ + The section of the trip planner page that shows the map and + the summary or details panel + """ + + use DotcomWeb, :component + + import DotcomWeb.Components.TripPlanner.{ItineraryDetail, ItinerarySummary} + + def results(assigns) do + ~H""" +
+
0 && @results.itinerary_group_selection} + class="h-min w-full p-4" + > + +
+
0} class="w-full p-4 row-start-2 col-start-1"> + <.itinerary_panel results={@results} /> +
+
+ """ + end + + defp itinerary_panel(%{results: %{itinerary_group_selection: nil}} = assigns) do + ~H""" +
+
+ {summary.tag} +
+ <.itinerary_summary summary={summary} /> +
+
0} class="grow text-sm text-grey-dark"> + Similar trips depart at {Enum.map( + summary.next_starts, + &Timex.format!(&1, "%-I:%M", :strftime) + ) + |> Enum.join(", ")} +
+ +
+
+ """ + end + + defp itinerary_panel(assigns) do + itinerary_group = + Enum.at(assigns.results.itinerary_groups, assigns.results.itinerary_group_selection) + + assigns = %{ + summary: itinerary_group.summary, + results: assigns.results + } + + ~H""" +
+
+ <.itinerary_summary summary={@summary} /> + <.itinerary_detail results={@results} /> +
+
+ """ + end +end diff --git a/lib/dotcom_web/components/trip_planner/results_summary.ex b/lib/dotcom_web/components/trip_planner/results_summary.ex new file mode 100644 index 0000000000..f8bb45513e --- /dev/null +++ b/lib/dotcom_web/components/trip_planner/results_summary.ex @@ -0,0 +1,52 @@ +defmodule DotcomWeb.Components.TripPlanner.ResultsSummary do + @moduledoc false + + use DotcomWeb, :component + + alias Dotcom.TripPlan.InputForm + + def results_summary(assigns) do + ~H""" +
0 || + (@changeset.action && @changeset.valid?) + } + class="mt-2 mb-6" + > +

{submission_summary(@changeset.changes)}

+

{time_summary(@changeset.changes)}

+ <%= if @results.loading? do %> + <.spinner aria_label="Waiting for results" /> Waiting for results... + <% else %> + <%= if @results.error do %> + <.feedback kind={:error}>{@results.error} + <% end %> + <%= if Enum.count(@results.itinerary_groups) == 0 do %> + <.feedback kind={:warning}>No trips found. + <% else %> + <.feedback kind={:success}> + Found {Enum.count(@results.itinerary_groups)} {Inflex.inflect( + "way", + Enum.count(@results.itinerary_groups) + )} to go. + + <% end %> + <% end %> +
+ """ + end + + defp submission_summary(%{from: from, to: to, modes: modes}) do + modes_string = modes.changes |> InputForm.Modes.selected_modes() |> String.downcase() + + "Planning trips from #{from.changes.name} to #{to.changes.name} using #{modes_string}" + end + + defp time_summary(%{datetime: datetime, datetime_type: datetime_type}) do + preamble = if datetime_type == "arrive_by", do: "Arriving by ", else: "Leaving at " + time_description = Timex.format!(datetime, "{h12}:{m}{am}") + date_description = Timex.format!(datetime, "{WDfull}, {Mfull} {D}") + preamble <> time_description <> " on " <> date_description + end +end diff --git a/lib/dotcom_web/components/trip_planner/trip_planner_results_section.ex b/lib/dotcom_web/components/trip_planner/trip_planner_results_section.ex deleted file mode 100644 index 240a526cc5..0000000000 --- a/lib/dotcom_web/components/trip_planner/trip_planner_results_section.ex +++ /dev/null @@ -1,156 +0,0 @@ -defmodule DotcomWeb.Components.TripPlanner.TripPlannerResultsSection do - @moduledoc """ - The section of the trip planner page that shows the map and - the summary or details panel - """ - - use DotcomWeb, :component - - import DotcomWeb.Components.TripPlanner.{ItineraryDetail, ItinerarySummary} - - alias Dotcom.TripPlan - - def trip_planner_results_section( - %{itinerary_selection: itinerary_selection, results: itinerary_results} = assigns - ) do - assigns = - if itinerary_results.result && itinerary_selection != :summary do - {:detail, - %{itinerary_group_index: itinerary_group_index, itinerary_index: itinerary_index}} = - itinerary_selection - - itinerary = - Enum.at(itinerary_results.result, itinerary_group_index).itineraries - |> Enum.at(itinerary_index) - - itinerary_map = TripPlan.Map.itinerary_map(itinerary) - lines = TripPlan.Map.get_lines(itinerary_map) - points = TripPlan.Map.get_points(itinerary_map) - - assign(assigns, %{lines: lines, points: points}) - else - assign(assigns, %{lines: [], points: []}) - end - - ~H""" -
-
- {inspect(@error)} -
- <.async_result :let={results} assign={@results}> -
- -
- - - <.live_component - module={MbtaMetro.Live.Map} - id="trip-planner-map" - class={[ - "h-64 md:h-96 w-full", - "relative overflow-none row-span-2", - @itinerary_selection == :summary && "hidden md:block" - ]} - config={@map_config} - lines={@lines} - pins={[@from, @to]} - points={@points} - /> - - <.async_result :let={results} assign={@results}> -
- <.itinerary_panel results={results} itinerary_selection={@itinerary_selection} /> -
- -
- """ - end - - defp itinerary_panel( - %{ - results: results, - itinerary_selection: - {:detail, - %{ - itinerary_group_index: itinerary_group_index, - itinerary_index: itinerary_index - }} - } = assigns - ) do - with %{itineraries: itineraries, summary: summary} <- - results |> Enum.at(itinerary_group_index) do - assigns = - assigns - |> assign(:itineraries, itineraries) - |> assign(:summary, summary) - |> assign(:itinerary_index, itinerary_index) - - ~H""" -
-
- <.itinerary_summary summary={@summary} /> -
- - <.itinerary_detail - itineraries={@itineraries} - selected_itinerary_detail_index={@itinerary_index} - /> -
- """ - end - end - - defp itinerary_panel(assigns) do - ~H""" -
-
- {summary.tag} -
- <.itinerary_summary summary={summary} /> -
-
0} class="grow text-sm text-grey-dark"> - Similar trips depart at {Enum.map(summary.next_starts, &format_datetime_short/1) - |> Enum.join(", ")} -
- -
-
- """ - end - - defp format_datetime_short(datetime) do - Timex.format!(datetime, "%-I:%M", :strftime) - end -end diff --git a/lib/dotcom_web/live/trip_planner.ex b/lib/dotcom_web/live/trip_planner.ex index 7e304e2b62..82c9b65700 100644 --- a/lib/dotcom_web/live/trip_planner.ex +++ b/lib/dotcom_web/live/trip_planner.ex @@ -7,190 +7,364 @@ defmodule DotcomWeb.Live.TripPlanner do use DotcomWeb, :live_view - import MbtaMetro.Components.{Feedback, Spinner} - import DotcomWeb.Components.TripPlanner.TripPlannerResultsSection + import DotcomWeb.Components.TripPlanner.{InputForm, Results, ResultsSummary} - alias DotcomWeb.Components.LiveComponents.{TripPlannerForm} - alias Dotcom.TripPlan.{AntiCorruptionLayer, InputForm.Modes, ItineraryGroups} + alias Dotcom.TripPlan + alias Dotcom.TripPlan.{AntiCorruptionLayer, InputForm, InputForm, ItineraryGroups} - @form_id "trip-planner-form" - - @map_config Application.compile_env!(:mbta_metro, :map) + @state %{ + input_form: %{ + changeset: %Ecto.Changeset{} + }, + map: %{ + config: Application.compile_env(:mbta_metro, :map), + lines: [], + pins: [], + points: [] + }, + results: %{ + error: nil, + itinerary_groups: [], + itinerary_group_selection: nil, + itinerary_selection: nil, + loading?: false + } + } @impl true + @doc """ + Prepare the live view: + + - Set the initial state of the live view. + - Clean any query parameters and convert them to a changeset for the input form. + - Then, submit the form if the changeset is valid (i.e., the user visited with valid query parameters). + """ def mount(params, _session, socket) do - socket = + changeset = query_params_to_changeset(params) + + new_socket = socket - |> assign(:error, nil) - |> assign(:form_name, @form_id) - |> assign(:form_values, AntiCorruptionLayer.convert_old_params(params)) - |> assign(:map_config, @map_config) - |> assign(:from, []) - |> assign(:to, []) - |> assign(:submitted_values, nil) - |> assign_async(:results, fn -> - {:ok, %{results: nil}} - end) - |> assign(:itinerary_selection, :summary) + |> assign(@state) + |> assign(:input_form, Map.put(@state.input_form, :changeset, changeset)) + |> maybe_submit_form() - {:ok, socket} + {:ok, new_socket} end @impl true + @doc """ + The live view is made up of four subcomponents: + + - InputForm: The form for the user to input their trip details + - ResultsSummary: A summary of the user's input and the number of results found + - Map: A map showing the user's trip details based on results and the selected itinerary group + - Results: Itinerary groups and itinerary details + """ def render(assigns) do ~H"""

Trip Planner Preview

- <.live_component - module={TripPlannerForm} - id={@form_name} - form_name={@form_name} - form_values={@form_values} - /> -
-

{submission_summary(@submitted_values)}

-

{time_summary(@submitted_values)}

- <.async_result :let={results} assign={@results}> - <:failed :let={{:error, errors}}> - <.error_container title="Unable to plan your trip"> -

- {message} -

- - - <:loading> - <.spinner aria_label="Waiting for results" /> Waiting for results... - - <%= if results do %> - <%= if Enum.count(results) == 0 do %> - <.feedback kind={:warning}>No trips found. - <% else %> - <.feedback kind={:success}> - Found {Enum.count(results)} {Inflex.inflect("way", Enum.count(results))} to go. - - <% end %> - <% end %> - -
- - <.trip_planner_results_section - results={@results} - error={@error} - map_config={@map_config} - from={@from} - to={@to} - itinerary_selection={@itinerary_selection} - /> + <.input_form changeset={@input_form.changeset} /> + <.results_summary changeset={@input_form.changeset} results={@results} /> +
0 && "md:grid-cols-2", + Enum.count(@results.itinerary_groups) == 0 && "md:grid-cols-1" + ]}> + <.live_component + module={MbtaMetro.Live.Map} + id="trip-planner-map" + class={[ + "h-64 md:h-96 w-full", + @results.itinerary_group_selection == nil && "hidden md:block", + @results.itinerary_group_selection != nil && "block" + ]} + config={@map.config} + lines={@map.lines} + pins={@map.pins} + points={@map.points} + /> + <.results class="row-start-1 col-start-1" results={@results} /> +
""" end @impl true - def handle_event("set_itinerary_group_index", %{"index" => index_str}, socket) do - itinerary_selection = - case Integer.parse(index_str) do - {index, ""} -> - {:detail, %{itinerary_group_index: index, itinerary_index: 0}} + # When itinerary groups are found, we add them to the results state. + def handle_async("get_itinerary_groups", {:ok, itinerary_groups}, socket) + when is_list(itinerary_groups) do + new_socket = + socket + |> assign(:results, Map.put(@state.results, :itinerary_groups, itinerary_groups)) + + {:noreply, new_socket} + end + + @impl true + # Triggered by OTP errors, we combine them into a single error message and add it to the results state. + def handle_async("get_itinerary_groups", {:ok, {:error, errors}}, socket) do + error = + errors + |> Enum.map_join(", ", &Map.get(&1, :message)) + + new_socket = + socket + |> assign(:results, Map.put(@state.results, :error, error)) + + {:noreply, new_socket} + end + + @impl true + # Triggered when the async operation fails, we add the error to the results state. + def handle_async("get_itinerary_groups", {:exit, reason}, socket) do + new_socket = + socket + |> assign(:results, Map.put(@state.results, :error, reason)) + + {:noreply, new_socket} + end + + @impl true + # Triggered every time the form changes: + # + # - Update the input form state with the new changeset + # - Update the map state with the new pins + # - Reset the results state + def handle_event("input_form_change", %{"input_form" => params}, socket) do + changeset = InputForm.changeset(params) + pins = input_form_to_pins(changeset) - _ -> - :summary - end + new_socket = + socket + |> assign(:input_form, Map.put(@state.input_form, :changeset, changeset)) + |> assign(:map, Map.put(@state.map, :pins, pins)) + |> assign(:results, @state.results) + |> maybe_round_datetime() - {:noreply, - socket - |> assign( - :itinerary_selection, - itinerary_selection - )} + {:noreply, new_socket} end @impl true - def handle_event( - "set_itinerary_index", - %{"trip-index" => index_str}, - %{assigns: %{itinerary_selection: {:detail, itinerary_selection}}} = socket - ) do - {index, ""} = Integer.parse(index_str) + # Triggered when the form is submitted: + # + # - Convert the params to a changeset and submit it + def handle_event("input_form_submit", %{"input_form" => params}, socket) do + new_socket = + submit_changeset(socket, InputForm.changeset(params)) - {:noreply, - socket - |> assign( - :itinerary_selection, - {:detail, %{itinerary_selection | itinerary_index: index}} - )} + {:noreply, new_socket} end @impl true - def handle_event(_event, _params, socket) do - {:noreply, socket} + # Triggered when the user selects to view all itinerary groups after selecting a particular one + # + # - Reset the itinerary group and itinerary selections + def handle_event("reset_itinerary_group", _, socket) do + new_results = %{ + itinerary_group_selection: nil, + itinerary_selection: nil + } + + new_socket = + socket + |> assign(:results, Map.merge(socket.assigns.results, new_results)) + + {:noreply, new_socket} end @impl true - def handle_info({:changed_form, params}, socket) do + # Triggered when the user selects a particular itinerary + # + # - Update the itinerary selection + def handle_event("select_itinerary", %{"index" => index}, socket) do + index = String.to_integer(index) + new_socket = socket - |> update_from_pin(params) - |> update_to_pin(params) + |> assign(:results, Map.put(socket.assigns.results, :itinerary_selection, index)) {:noreply, new_socket} end @impl true - def handle_info({:updated_form, %Dotcom.TripPlan.InputForm{} = data}, socket) do - socket = + # Triggered when the user selects a particular itinerary group + # + # - Update the itinerary group selection + # - Update the map state with the new lines and points + def handle_event("select_itinerary_group", %{"index" => index}, socket) do + index = String.to_integer(index) + + new_map = %{ + lines: itinerary_groups_to_lines(socket.assigns.results.itinerary_groups, index), + points: itinerary_groups_to_points(socket.assigns.results.itinerary_groups, index) + } + + new_socket = socket - |> assign(:submitted_values, data) - |> assign(:results, nil) - |> assign_async(:results, fn -> - case Dotcom.TripPlan.OpenTripPlanner.plan(data) do - {:ok, itineraries} -> - {:ok, %{results: ItineraryGroups.from_itineraries(itineraries)}} - - error -> - error - end - end) + |> assign(:results, Map.put(socket.assigns.results, :itinerary_group_selection, index)) + |> assign(:map, Map.merge(socket.assigns.map, new_map)) + {:noreply, new_socket} + end + + @impl true + # Default if we receieve an event we don't handle. + def handle_event(_event, _params, socket) do {:noreply, socket} end + @impl true + # Default if we receive an info message we don't handle. def handle_info(_info, socket) do {:noreply, socket} end - defp update_from_pin(socket, %{"from" => from}) do - assign(socket, :from, to_geojson(from)) + # Run an OTP plan on the changeset data and return itinerary groups or an error. + defp get_itinerary_groups(%Ecto.Changeset{valid?: true} = changeset) do + {:ok, data} = Ecto.Changeset.apply_action(changeset, :submit) + + case Dotcom.TripPlan.OpenTripPlanner.plan(data) do + {:ok, itineraries} -> + ItineraryGroups.from_itineraries(itineraries) + + error -> + error + end end - defp update_from_pin(socket, _params) do - socket + # If the changeset is invalid, we return an empty list of itinerary groups. + defp get_itinerary_groups(_), do: [] + + # Convert the input form changeset to a list of pins for the map. + # I.e., add pins for the from and to locations. + defp input_form_to_pins(%{changes: %{from: from, to: to}}) do + [to_geojson(from.changes), to_geojson(to.changes)] end - defp update_to_pin(socket, %{"to" => to}) do - assign(socket, :to, to_geojson(to)) + # If `from` is set but `to` isn't, then we return only the one pin. + defp input_form_to_pins(%{changes: %{from: from}}) do + [to_geojson(from.changes)] end - defp update_to_pin(socket, _params) do - socket + # If `to` is set but `from` isn't, we return an empty first pin so that to shows up as the 'B' pin. + defp input_form_to_pins(%{changes: %{to: to}}) do + [[], to_geojson(to.changes)] + end + + # If neither `from` nor `to` are set, we return an empty list of pins. + defp input_form_to_pins(_), do: [] + + # Get the itinerary group at the given index and convert it to a map. + # Selects a random itinerary from the group as they will all be the same. + defp itinerary_groups_to_itinerary_map(itinerary_groups, index) do + itinerary_groups + |> Enum.at(index) + |> Map.get(:itineraries) + |> Enum.random() + |> TripPlan.Map.itinerary_map() + end + + # Get the itinerary map at the given index and convert it to lines. + defp itinerary_groups_to_lines(itinerary_groups, index) do + itinerary_groups + |> itinerary_groups_to_itinerary_map(index) + |> TripPlan.Map.get_lines() + end + + # Get the itinerary map at the given index and convert it to points. + defp itinerary_groups_to_points(itinerary_groups, index) do + itinerary_groups + |> itinerary_groups_to_itinerary_map(index) + |> TripPlan.Map.get_points() + end + + # Round the datetime to the nearest 5 minutes if: + # + # - The datetime type is not 'now' + # - The datetime is before the nearest 5 minutes + # + # We standardize the datetime because it could be a NaiveDateTime or a DateTime or nil. + defp maybe_round_datetime(socket) do + datetime = + socket.assigns.input_form.changeset.changes + |> Map.get(:datetime) + |> standardize_datetime() + + datetime_type = socket.assigns.input_form.changeset.changes.datetime_type + future = nearest_5_minutes() + + if datetime_type != "now" && Timex.before?(datetime, future) do + push_event(socket, "set-datetime", %{datetime: future}) + else + socket + end + end + + # Check the input form change set for validity and submit the form if it is. + defp maybe_submit_form(socket) do + if socket.assigns.input_form.changeset.valid? do + submit_changeset(socket, socket.assigns.input_form.changeset) + else + socket + end + end + + # Round the current time to the nearest 5 minutes. + defp nearest_5_minutes do + datetime = Timex.now("America/New_York") + minutes = datetime.minute + rounded_minutes = Float.ceil(minutes / 5) * 5 + added_minutes = Kernel.trunc(rounded_minutes - minutes) + + Timex.shift(datetime, minutes: added_minutes) + end + + # Convert query parameters to a changeset for the input form. + # Use an anti corruption layer to convert old query parameters to new ones. + defp query_params_to_changeset(params) do + %{ + "datetime" => Timex.now("America/New_York"), + "datetime_type" => "now", + "modes" => InputForm.initial_modes() + } + |> Map.merge(AntiCorruptionLayer.convert_old_params(params)) + |> InputForm.changeset() end - defp to_geojson(%{"longitude" => longitude, "latitude" => latitude}) - when longitude != "" and latitude != "" do - [String.to_float(longitude), String.to_float(latitude)] + # Destructure the latitude and longitude from a map to a GeoJSON array. + defp to_geojson(%{longitude: longitude, latitude: latitude}) do + [longitude, latitude] end - defp to_geojson(_coordinates) do + defp to_geojson(_) do [] end - defp submission_summary(%{from: %{name: from_name}, to: %{name: to_name}, modes: modes}) do - "Planning trips from #{from_name} to #{to_name} using #{Modes.selected_modes(modes)}" + # Convert a NaiveDateTime to a DateTime in the America/New_York timezone. + defp standardize_datetime(%NaiveDateTime{} = datetime) do + Timex.to_datetime(datetime, "America/New_York") end - defp time_summary(%{datetime_type: datetime_type, datetime: datetime}) do - preamble = if datetime_type == :arrive_by, do: "Arriving by ", else: "Leaving at " - time_description = Timex.format!(datetime, "{h12}:{m}{am}") - date_description = Timex.format!(datetime, "{WDfull}, {Mfull} {D}") - preamble <> time_description <> " on " <> date_description + # The lack of a datetime means we should use the nearest 5 minutes. + defp standardize_datetime(nil), do: nearest_5_minutes() + + # If the datetime is already a DateTime, we don't need to do anything. + defp standardize_datetime(datetime), do: datetime + + # Set an action on the changeset and submit it. + # + # - Update the input form state with the new changeset + # - Update the map state with the new pins + # - Set the results state to loading + # - Start an async operation to get the itinerary groups + defp submit_changeset(socket, changeset) do + new_changeset = Map.put(changeset, :action, :submit) + + socket + |> assign(:input_form, Map.put(@state.input_form, :changeset, new_changeset)) + |> assign(:map, Map.put(@state.map, :pins, input_form_to_pins(new_changeset))) + |> assign(:results, Map.put(@state.results, :loading?, true)) + |> start_async("get_itinerary_groups", fn -> get_itinerary_groups(new_changeset) end) end end diff --git a/test/dotcom/trip_plan/input_form_test.exs b/test/dotcom/trip_plan/input_form_test.exs index aa7c51da63..b4aecf24c2 100644 --- a/test/dotcom/trip_plan/input_form_test.exs +++ b/test/dotcom/trip_plan/input_form_test.exs @@ -32,15 +32,15 @@ defmodule Dotcom.TripPlan.InputFormTest do assert {_, [validation: :required]} = changeset.errors[:modes] end - describe "validate_params/1" do + describe "changeset/1" do test "validates to & from" do - changeset = InputForm.validate_params(@params) + changeset = InputForm.changeset(@params) assert changeset.valid? end test "adds from & to errors" do changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ "from" => %{ "latitude" => "", "longitude" => "", @@ -62,7 +62,7 @@ defmodule Dotcom.TripPlan.InputFormTest do test "adds error if from & to are the same" do changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ "from" => @from_params, "to" => @from_params }) @@ -73,22 +73,9 @@ defmodule Dotcom.TripPlan.InputFormTest do assert {^expected_error, _} = changeset.errors[:to] end - test "at least one mode required" do - changeset = - InputForm.validate_params(%{ - @params - | "modes" => %{RAIL: false, BUS: false, FERRY: false, SUBWAY: false} - }) - - refute changeset.valid? - - expected_error = InputForm.error_message(:modes) - assert {^expected_error, _} = changeset.errors[:modes] - end - test "adds datetime if using datetime_type == now" do changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ @params | "datetime_type" => "now", "datetime" => nil @@ -102,7 +89,7 @@ defmodule Dotcom.TripPlan.InputFormTest do expected_error = InputForm.error_message(:datetime) changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ @params | "datetime_type" => "arrive_by", "datetime" => nil @@ -112,7 +99,7 @@ defmodule Dotcom.TripPlan.InputFormTest do assert {^expected_error, _} = changeset.errors[:datetime] changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ @params | "datetime_type" => "leave_at", "datetime" => nil @@ -124,7 +111,7 @@ defmodule Dotcom.TripPlan.InputFormTest do test "requires date to be in the future" do changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ @params | "datetime_type" => "arrive_by", "datetime" => Faker.DateTime.forward(1) @@ -135,7 +122,7 @@ defmodule Dotcom.TripPlan.InputFormTest do expected_error = InputForm.error_message(:datetime) changeset = - InputForm.validate_params(%{ + InputForm.changeset(%{ @params | "datetime_type" => "arrive_by", "datetime" => Faker.DateTime.backward(1) diff --git a/test/dotcom_web/components/live_components/trip_planner/trip_planner_form_test.exs b/test/dotcom_web/components/live_components/trip_planner/trip_planner_form_test.exs deleted file mode 100644 index 91dc28e6a3..0000000000 --- a/test/dotcom_web/components/live_components/trip_planner/trip_planner_form_test.exs +++ /dev/null @@ -1,23 +0,0 @@ -defmodule DotcomWeb.Components.LiveComponents.TripPlannerFormTest do - use ExUnit.Case, async: true - - import Phoenix.LiveViewTest - alias DotcomWeb.Components.LiveComponents.TripPlannerForm - - test "renders the needed inputs" do - html = - render_component(TripPlannerForm, %{ - id: "my_form", - form_name: "my_form" - }) - - assert html =~ - ~s(
) - - assert html =~ ~s(name="input_form[from]) - assert html =~ ~s(name="input_form[to]) - assert html =~ ~s(name="input_form[datetime_type]) - assert html =~ ~s(name="input_form[wheelchair]) - assert html =~ ~s(name="input_form[modes]) - end -end