diff --git a/assets/ts/phoenix-hooks/algolia-autocomplete.ts b/assets/ts/phoenix-hooks/algolia-autocomplete.ts index 78676ff0ec..c145675e09 100644 --- a/assets/ts/phoenix-hooks/algolia-autocomplete.ts +++ b/assets/ts/phoenix-hooks/algolia-autocomplete.ts @@ -23,7 +23,7 @@ const AlgoliaAutocomplete: Partial = { const pushToLiveView = (data: Partial): void => { if (hook.el.querySelector("[data-config='trip-planner']")) { // this will fail outside of a LiveView, that's fine - hook.pushEventTo(hook.el, "map_change", { + hook.pushEvent("map_change", { id: hook.el.id, ...data }); @@ -46,8 +46,6 @@ const AlgoliaAutocomplete: Partial = { }) ); - // side effect: dispatch event to LV to get initial map markers - pushToLiveView(inputValues); return inputValues.name || ""; }; diff --git a/lib/dotcom/trip_plan/input_form.ex b/lib/dotcom/trip_plan/input_form.ex index abfa1e32dc..83eb4b54c2 100644 --- a/lib/dotcom/trip_plan/input_form.ex +++ b/lib/dotcom/trip_plan/input_form.ex @@ -8,44 +8,52 @@ defmodule Dotcom.TripPlan.InputForm do use TypedEctoSchema import Ecto.Changeset + @valid_modes [:commuter_rail, :subway, :bus, :ferry] + @time_types [:now, :leave_at, :arrive_by] + @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." + from_to_same: "Please select a destination at a different location from the origin.", + modes: "Please select at least one mode of transit.", + datetime: "Please specify a date and time in the future or select 'Now'." } @primary_key false typed_embedded_schema do embeds_one(:from, __MODULE__.Location) embeds_one(:to, __MODULE__.Location) + field(:datetime_type, Ecto.Enum, values: @time_types) + field(:datetime, :naive_datetime) + field(:modes, {:array, Ecto.Enum}, values: @valid_modes) + field(:wheelchair, :boolean, default: true) end + def time_types, do: @time_types + def valid_modes, do: @valid_modes + def changeset(params \\ %{}) do changeset(%__MODULE__{}, params) end def changeset(form, params) do form - |> cast(params, []) + |> cast(params, [:datetime_type, :datetime, :modes, :wheelchair]) |> 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 + 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_required([:datetime_type, :modes, :wheelchair]) + |> validate_same_locations() + |> validate_length(:modes, min: 1, message: error_message(:modes)) + |> validate_chosen_datetime() end # make the parent field blank if the location isn't valid @@ -66,6 +74,24 @@ defmodule Dotcom.TripPlan.InputForm do end end + defp validate_chosen_datetime(changeset) do + case get_field(changeset, :datetime_type) do + :now -> + force_change(changeset, :datetime, NaiveDateTime.local_now()) + + _ -> + changeset + |> validate_change(:datetime, &validate_datetime/2) + end + end + + defp validate_datetime(field, date) do + case Timex.compare(date, Util.now(), :hours) do + -1 -> [{field, error_message(:datetime)}] + _ -> [] + end + end + def error_message(key), do: @error_messages[key] defmodule Location do diff --git a/lib/dotcom_web/components/live_components/trip_planner_form.ex b/lib/dotcom_web/components/live_components/trip_planner_form.ex new file mode 100644 index 0000000000..1b6f872191 --- /dev/null +++ b/lib/dotcom_web/components/live_components/trip_planner_form.ex @@ -0,0 +1,194 @@ +defmodule DotcomWeb.Components.LiveComponents.TripPlannerForm do + @moduledoc """ + A form to plan trips. + """ + use DotcomWeb, :live_component + + import DotcomWeb.ViewHelpers, only: [mode_name: 1, svg: 1] + import Phoenix.HTML.Form, only: [input_name: 2, input_value: 2, input_id: 2] + + alias Dotcom.TripPlan.InputForm + + @form_defaults %{ + "datetime_type" => :now, + "datetime" => NaiveDateTime.local_now(), + "modes" => [:commuter_rail, :subway, :bus, :ferry], + "wheelchair" => true + } + + @impl true + def mount(socket) do + form = + %InputForm{} + |> InputForm.changeset(@form_defaults) + |> to_form() + + {:ok, + assign(socket, %{ + form: form, + location_keys: InputForm.Location.fields(), + show_datepicker: false + })} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.form + :let={f} + for={@form} + method="get" + phx-change="validate" + phx-submit="save_form" + phx-target={@myself} + > +
+ <.algolia_autocomplete + config_type="trip-planner" + placeholder="Enter a location" + id={"#{@form_name}--#{field}"} + > + <.inputs_for :let={location_f} field={f[field]} skip_hidden={true}> + + + <.form_error :for={{msg, _} <- f[field].errors} :if={used_input?(f[field])}> + <%= msg %> + + +
+ <.fieldset legend="When"> + <.input_group + id={input_id(@form, :datetime_type)} + field={f[:datetime_type]} + type="radio" + phx-click="toggle_datepicker" + phx-target={@myself} + phx-update="ignore" + > + <:input_item + :for={type <- Ecto.Enum.values(InputForm, :datetime_type)} + id={input_name(@form, :datetime_type) <> "_#{type}"} + value={type} + checked={input_value(@form, :datetime_type) == type} + /> + + + <.form_error + :for={{msg, _} <- f[:datetime_type].errors} + :if={used_input?(f[:datetime_type])} + > + <%= msg %> + + <.form_label :if={@show_datepicker} for="timepick"> + + Date and time to leave at or arrive by + + <.form_error + :for={{msg, _} <- f[:datetime_type].errors} + :if={used_input?(f[:datetime_type])} + > + <%= msg %> + + <.form_error :for={{msg, _} <- f[:datetime].errors} :if={used_input?(f[:datetime])}> + <%= msg %> + + + <.fieldset legend="Modes"> + <.accordion open> + <:heading> + <%= input_value(@form, :modes) |> selected_modes() %> + + <:content> + <.mode_selector field={f[:modes]} modes={modes()} /> + + <:extra :if={used_input?(f[:modes])}> + <.form_error :for={{msg, _} <- f[:modes].errors}> + <%= msg %> + + + + +
+ <.form_input type="checkbox" field={f[:wheelchair]} label="Prefer accessible routes" /> + <%= svg("icon-accessible-small.svg") %> +
+ <.button type="submit" phx-disable-with="Planning your trip..."> + Get trip suggestions + + +
+ """ + end + + @impl true + def handle_event("toggle_datepicker", %{"value" => datetime_value}, socket) do + {:noreply, assign(socket, :show_datepicker, datetime_value !== "now")} + end + + def handle_event("validate", %{"input_form" => params}, socket) do + 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 + params + |> InputForm.validate_params() + |> Ecto.Changeset.apply_action(:update) + |> case do + {:ok, data} -> + _ = socket.assigns.on_submit.(data) + + data + |> Ecto.Changeset.change() + |> Phoenix.Component.to_form() + + {:error, changeset} -> + changeset + |> Phoenix.Component.to_form() + end + |> then(fn form -> + {:noreply, assign(socket, %{form: form})} + end) + end + + defp modes do + InputForm + |> Ecto.Enum.values(:modes) + |> Enum.map(&{mode_name(&1), &1}) + end + + defp selected_modes([:commuter_rail, :subway, :bus, :ferry]) do + "All modes" + end + + defp selected_modes([]), do: "No transit modes selected" + + defp selected_modes([mode]), do: mode_name(mode) <> " Only" + defp selected_modes([mode1, mode2]), do: mode_name(mode1) <> " and " <> mode_name(mode2) + + defp selected_modes(modes) do + modes + |> Enum.map(&mode_name/1) + |> Enum.intersperse(", ") + |> List.insert_at(-2, "and ") + |> Enum.join("") + end +end diff --git a/lib/dotcom_web/components/trip_planner.ex b/lib/dotcom_web/components/trip_planner.ex deleted file mode 100644 index 41dbedfbcb..0000000000 --- a/lib/dotcom_web/components/trip_planner.ex +++ /dev/null @@ -1,98 +0,0 @@ -defmodule DotcomWeb.Components.TripPlannerForm do - @moduledoc """ - Reusable components mainly used for the Trip Planner - """ - use DotcomWeb, :component - - alias Dotcom.TripPlan.InputForm - - attr :id, :string - attr :params, :map, default: %{} - attr :phx_submit_handler, :string, default: nil - attr :on_validated_pid, :any - - attr :do_validation, :boolean, - default: false, - doc: "Whether to run the form validation on render." - - @doc """ - A form to plan trips. Use in a LiveView and specify the phx-action: - - ``` <.TripPlanner.input_form params={%{}} id="widget" phx_submit_handler="my_form_save_event" /> ``` - - With no phx-action provided, we'll assume we're outside a LiveView, and will - use an action to submit to the trip planner page. - """ - def input_form(assigns) do - assigns = - assigns - |> assign(:action, assign_action(assigns)) - |> assign(:form, create_form(assigns)) - |> assign(:location_keys, InputForm.Location.fields()) - - ~H""" - <.form - :let={f} - for={@form} - action={@action} - method="get" - phx-change="input_change" - phx-submit={@phx_submit_handler} - > -
- <.algolia_autocomplete - config_type="trip-planner" - placeholder="Enter a location" - id={"#{@id}--#{field}"} - > - <.inputs_for :let={location_f} field={f[field]} skip_hidden={true}> - - -

- <%= msg %> -

- -
- - - """ - end - - defp create_form(%{do_validation: true, params: params, on_validated_pid: on_validated_pid}) - when is_pid(on_validated_pid) do - changeset = InputForm.validate_params(params) - - case Ecto.Changeset.apply_action(changeset, :insert) do - {:ok, data} -> - send(on_validated_pid, {:updated_form, data}) - - data - |> InputForm.changeset(%{}) - |> Phoenix.Component.to_form(as: "plan") - - {:error, changeset} -> - send(on_validated_pid, {:updated_form, nil}) - Phoenix.Component.to_form(changeset, as: "plan") - end - end - - defp create_form(%{params: params}) do - params - |> InputForm.changeset() - |> Phoenix.Component.to_form(as: "plan") - end - - defp assign_action(%{phx_submit_handler: handler}) when is_nil(handler), - do: "/preview/trip-planner" - - defp assign_action(_), do: nil -end diff --git a/lib/dotcom_web/live/trip_planner.ex b/lib/dotcom_web/live/trip_planner.ex index 01894cc657..61e656fd5b 100644 --- a/lib/dotcom_web/live/trip_planner.ex +++ b/lib/dotcom_web/live/trip_planner.ex @@ -7,22 +7,13 @@ defmodule DotcomWeb.Live.TripPlanner do use DotcomWeb, :live_view - import DotcomWeb.Components.TripPlannerForm + alias DotcomWeb.Components.LiveComponents.TripPlannerForm @form_id "trip-planner-form" @impl true - def mount(params, _session, socket) do - pid = self() - {:ok, assign(socket, :params, plan_params(params)) |> assign(:pid, pid)} - end - - defp plan_params(%{"plan" => params}), do: params - defp plan_params(_), do: %{} - - @impl true - def handle_params(params, _uri, socket) do - {:noreply, assign(socket, :params, plan_params(params))} + def mount(_params, _session, socket) do + {:ok, socket} end @impl true @@ -30,19 +21,17 @@ defmodule DotcomWeb.Live.TripPlanner do assigns = assigns |> assign_new(:submitted_values, fn -> nil end) - |> assign_new(:do_validation, fn -> false end) - |> assign(:form_id, @form_id) + |> assign(:form_name, @form_id) ~H"""

Trip Planner Preview

- <.input_form - params={@params} - id={@form_id} - do_validation={@do_validation} - on_validated_pid={@pid} - phx_submit_handler="save_form" + <.live_component + module={TripPlannerForm} + id={@form_name} + form_name={@form_name} + on_submit={fn data -> send(self(), {:updated_form, data}) end} /> <%= inspect(@submitted_values) %> @@ -63,38 +52,17 @@ defmodule DotcomWeb.Live.TripPlanner do end @impl true - def handle_event("input_change", %{"plan" => params}, socket) do - merged_params = - socket.assigns.params - |> Map.merge(params, fn _, a, b -> Map.merge(a, b) end) - - {:noreply, - assign(socket, %{ - do_validation: false, - params: merged_params - })} - end - - @impl true - def handle_event("save_form", %{"plan" => params}, socket) do - {:noreply, - socket - |> assign(:do_validation, true) - |> push_patch(to: path_with_params(params), replace: true)} + def handle_event(_event, _params, socket) do + {:noreply, socket} end - @impl true - def handle_event(_, _, socket), do: {:noreply, socket} - @impl true def handle_info({:updated_form, data}, socket) do {:noreply, assign(socket, :submitted_values, data)} end - defp path_with_params(params) do - %{"plan" => params} - |> Plug.Conn.Query.encode() - |> then(&("/preview/trip-planner?" <> &1)) + def handle_info(_info, socket) do + {:noreply, socket} end # Selected from list of popular locations diff --git a/test/dotcom/trip_plan/input_form_test.exs b/test/dotcom/trip_plan/input_form_test.exs index 5e8eaba902..b7a334b0b3 100644 --- a/test/dotcom/trip_plan/input_form_test.exs +++ b/test/dotcom/trip_plan/input_form_test.exs @@ -11,9 +11,14 @@ defmodule Dotcom.TripPlan.InputFormTest do "latitude" => "#{Faker.Address.latitude()}", "longitude" => "#{Faker.Address.longitude()}" } + @mode_params InputForm.valid_modes() @params %{ "from" => @from_params, - "to" => @to_params + "to" => @to_params, + "datetime_type" => Faker.Util.pick(InputForm.time_types()) |> to_string(), + "datetime" => Faker.DateTime.forward(4) |> to_string(), + "modes" => @mode_params |> Enum.map(&to_string/1), + "wheelchair" => Faker.Util.pick(["true", "false"]) } test "from & to fields are required" do 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 new file mode 100644 index 0000000000..91dc28e6a3 --- /dev/null +++ b/test/dotcom_web/components/live_components/trip_planner/trip_planner_form_test.exs @@ -0,0 +1,23 @@ +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 diff --git a/test/dotcom_web/components/trip_planner_test.exs b/test/dotcom_web/components/trip_planner_test.exs deleted file mode 100644 index 40c09e922d..0000000000 --- a/test/dotcom_web/components/trip_planner_test.exs +++ /dev/null @@ -1,90 +0,0 @@ -defmodule DotcomWeb.Components.TripPlannerFormTest do - @moduledoc false - use ExUnit.Case - import Phoenix.LiveViewTest - import DotcomWeb.Components.TripPlannerForm - - describe "input_form" do - test "renders the needed inputs" do - html = - render_component(&input_form/1, %{ - id: "my_form" - }) - - assert html =~ ~s(
) - - assert html =~ ~s(name="plan[from]) - assert html =~ ~s(name="plan[to]) - end - - test "doesn't assign a form action if a submit handler provided" do - html = - render_component(&input_form/1, %{ - id: "my_form", - phx_submit_handler: "something" - }) - - refute html =~ ~s( from_lon, - "latitude" => from_lat - } - - to_lon = Faker.Address.longitude() - to_lat = Faker.Address.latitude() - - to_params = %{ - "longitude" => to_lon, - "latitude" => to_lat - } - - _ = - render_component(&input_form/1, %{ - id: "my_form", - do_validation: true, - on_validated_pid: pid, - params: %{ - "from" => from_params, - "to" => to_params - } - }) - - assert_receive {:updated_form, data} - - assert %Dotcom.TripPlan.InputForm{ - from: %Dotcom.TripPlan.InputForm.Location{ - latitude: ^from_lat, - longitude: ^from_lon - }, - to: %Dotcom.TripPlan.InputForm.Location{ - latitude: ^to_lat, - longitude: ^to_lon - } - } = data - end - end -end diff --git a/test/dotcom_web/live/trip_planner_test.exs b/test/dotcom_web/live/trip_planner_test.exs new file mode 100644 index 0000000000..c4749e96b5 --- /dev/null +++ b/test/dotcom_web/live/trip_planner_test.exs @@ -0,0 +1,101 @@ +defmodule DotcomWeb.Live.TripPlannerTest do + use DotcomWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + test "Preview version behind basic auth", %{conn: conn} do + conn = get(conn, ~p"/preview/trip-planner") + + {_header_name, header_value} = List.keyfind(conn.resp_headers, "www-authenticate", 0) + assert conn.status == 401 + assert header_value =~ "Basic" + end + + describe "Trip Planner" do + setup %{conn: conn} do + [username: username, password: password] = + Application.get_env(:dotcom, DotcomWeb.Router)[:basic_auth] + + {:ok, view, html} = + conn + |> put_req_header("authorization", "Basic " <> Base.encode64("#{username}:#{password}")) + |> live(~p"/preview/trip-planner") + + %{html: html, view: view} + end + + test "toggles the date input when changing from 'now'", %{html: html, view: view} do + date_input = ~s(name="input_form[datetime]") + refute html =~ date_input + + html = + view + |> element("input[value=arrive_by]") + |> render_click() + + assert html =~ date_input + + html = + view + |> element("input[value=now]") + |> render_click() + + refute html =~ date_input + end + + test "summarizes the selected modes", %{view: view, html: html} do + assert html =~ "All modes" + + html = + view + |> element("form") + |> render_change(%{ + _target: ["input_form", "modes"], + input_form: %{modes: [:commuter_rail, :subway, :ferry]} + }) + + assert html =~ "Commuter Rail, Subway, and Ferry" + + html = + view + |> element("form") + |> render_change(%{_target: ["input_form", "modes"], input_form: %{modes: [:subway]}}) + + assert html =~ "Subway Only" + + html = + view + |> element("form") + |> render_change(%{ + _target: ["input_form", "modes"], + input_form: %{modes: [:subway, :bus]} + }) + + assert html =~ "Subway and Bus" + end + + test "shows errors on form submit", %{view: view} do + html = + view + |> element("form") + |> render_submit() + + assert html =~ "Please add a destination." + end + + test "pushes updated location to the map", %{view: view} do + updated_location = %{ + "latitude" => Faker.Address.latitude(), + "longitude" => Faker.Address.longitude(), + "name" => Faker.Company.name() + } + + id = Faker.Internet.slug() + + view + |> render_hook(:map_change, Map.put_new(updated_location, "id", id)) + + assert_push_event(view, ^id, ^updated_location) + end + end +end