diff --git a/assets/src/app.tsx b/assets/src/app.tsx index 85c56534d..abfb1bd27 100644 --- a/assets/src/app.tsx +++ b/assets/src/app.tsx @@ -45,11 +45,24 @@ const sortable = { }, } as ViewHook +const LimitTime = { + mounted() { + this.el.addEventListener("input", () => { + const el = this.el as HTMLInputElement + const match = el.value.match(/^(\d{2})(\d{2})$/) + if (match) { + el.value = `${match[1]}:${match[2]}` + } + }) + }, +} as ViewHook + // https://github.com/fidr/phoenix_live_react const hooks = { LiveReact, sortable, ...live_select, + LimitTime, } const csrfToken = document diff --git a/lib/arrow/disruptions.ex b/lib/arrow/disruptions.ex index 3e0b04a0e..3dd892e82 100644 --- a/lib/arrow/disruptions.ex +++ b/lib/arrow/disruptions.ex @@ -396,4 +396,25 @@ defmodule Arrow.Disruptions do &(&1 |> Integer.to_string() |> String.pad_leading(2, "0")) ) end + + def start_end_dates(%DisruptionV2{limits: [], replacement_services: []}) do + {nil, nil} + end + + def start_end_dates(%DisruptionV2{ + limits: limits, + replacement_services: replacement_services + }) do + min_date = + (limits ++ replacement_services) + |> Enum.map(& &1.start_date) + |> Enum.min(Date, fn -> ~D[9999-12-31] end) + + max_date = + (limits ++ replacement_services) + |> Enum.map(& &1.end_date) + |> Enum.max(Date, fn -> ~D[0000-01-01] end) + + {min_date, max_date} + end end diff --git a/lib/arrow/shuttles/shuttle.ex b/lib/arrow/shuttles/shuttle.ex index eb7548222..b0ecd8314 100644 --- a/lib/arrow/shuttles/shuttle.ex +++ b/lib/arrow/shuttles/shuttle.ex @@ -2,6 +2,10 @@ defmodule Arrow.Shuttles.Shuttle do @moduledoc "schema for a shuttle for the db" use Ecto.Schema import Ecto.Changeset + import Ecto.Query + + alias Arrow.Disruptions.ReplacementService + alias Arrow.Repo @type id :: integer @type t :: %__MODULE__{ @@ -59,7 +63,24 @@ defmodule Arrow.Shuttles.Shuttle do end _ -> - changeset + id = get_field(changeset, :id) + + replacement_services = + if is_nil(id) do + [] + else + Repo.all(from r in ReplacementService, where: r.shuttle_id == ^id) + end + + if length(replacement_services) > 0 do + add_error( + changeset, + :status, + "cannot set to a non-active status while in use as a replacement service" + ) + else + changeset + end end end diff --git a/lib/arrow_web/components/core_components.ex b/lib/arrow_web/components/core_components.ex index 7eaa623f7..ac56ef53e 100644 --- a/lib/arrow_web/components/core_components.ex +++ b/lib/arrow_web/components/core_components.ex @@ -511,6 +511,7 @@ defmodule ArrowWeb.CoreComponents do attr :field, :any, required: true, doc: "Field for `shuttle_id` value" attr :shuttle, :any, required: true, doc: "Currently selected shuttle, if any" + attr :only_approved?, :boolean, default: false attr :label, :string, default: "Stop ID" attr :class, :string, default: nil @@ -527,6 +528,7 @@ defmodule ArrowWeb.CoreComponents do id={@id} field={@field} shuttle={@shuttle} + only_approved?={@only_approved?} class={@class} /> """ diff --git a/lib/arrow_web/components/limit_section.ex b/lib/arrow_web/components/limit_section.ex index 7ed2d49c1..801d98a88 100644 --- a/lib/arrow_web/components/limit_section.ex +++ b/lib/arrow_web/components/limit_section.ex @@ -159,6 +159,7 @@ defmodule ArrowWeb.LimitSection do :if={normalize_value("checkbox", input_value(f_day_of_week, :active?))} field={f_day_of_week[:start_time]} disabled={normalize_value("checkbox", input_value(f_day_of_week, :all_day?))} + phx-hook="LimitTime" />
@@ -166,6 +167,7 @@ defmodule ArrowWeb.LimitSection do :if={normalize_value("checkbox", input_value(f_day_of_week, :active?))} field={f_day_of_week[:end_time]} disabled={normalize_value("checkbox", input_value(f_day_of_week, :all_day?))} + phx-hook="LimitTime" />
diff --git a/lib/arrow_web/components/replacement_service_section.ex b/lib/arrow_web/components/replacement_service_section.ex index 21657dc26..7e053e2b3 100644 --- a/lib/arrow_web/components/replacement_service_section.ex +++ b/lib/arrow_web/components/replacement_service_section.ex @@ -116,7 +116,11 @@ defmodule ArrowWeb.ReplacementServiceSection do do: "add new replacement service component", else: "edit disruption replacement service component"} - <.shuttle_input field={@form[:shuttle_id]} shuttle={input_value(@form, :shuttle)} /> + <.shuttle_input + field={@form[:shuttle_id]} + shuttle={input_value(@form, :shuttle)} + only_approved?={true} + />
{live_react_component( diff --git a/lib/arrow_web/components/shuttle_input.ex b/lib/arrow_web/components/shuttle_input.ex index 019c6b717..4cfd4b13d 100644 --- a/lib/arrow_web/components/shuttle_input.ex +++ b/lib/arrow_web/components/shuttle_input.ex @@ -13,6 +13,7 @@ defmodule ArrowWeb.ShuttleInput do attr :id, :string, required: true attr :field, :any, required: true attr :shuttle, :any, required: true + attr :only_approved?, :boolean, default: false attr :label, :string, default: "select shuttle route" attr :class, :string, default: nil @@ -22,7 +23,9 @@ defmodule ArrowWeb.ShuttleInput do assigns, :options, if is_nil(assigns.shuttle) || !Ecto.assoc_loaded?(assigns.shuttle) do - Shuttles.list_shuttles() |> Enum.map(&option_for_shuttle/1) + Shuttles.list_shuttles() + |> filter_only_approved(assigns.only_approved?) + |> Enum.map(&option_for_shuttle/1) else [option_for_shuttle(assigns.shuttle)] end @@ -47,9 +50,14 @@ defmodule ArrowWeb.ShuttleInput do def handle_event("live_select_change", %{"id" => live_select_id, "text" => text}, socket) do new_opts = if String.length(text) == 0 do - Shuttles.list_shuttles() |> Enum.map(&option_for_shuttle/1) + Shuttles.list_shuttles() + |> filter_only_approved(socket.assigns.only_approved?) + |> Enum.map(&option_for_shuttle/1) else - text |> Shuttles.shuttles_by_search_string() |> Enum.map(&option_for_shuttle/1) + text + |> Shuttles.shuttles_by_search_string() + |> filter_only_approved(socket.assigns.only_approved?) + |> Enum.map(&option_for_shuttle/1) end send_update(LiveSelect.Component, id: live_select_id, options: new_opts) @@ -81,4 +89,11 @@ defmodule ArrowWeb.ShuttleInput do defp option_for_shuttle(%Shuttle{id: id, shuttle_name: shuttle_name}) do {shuttle_name, id} end + + @spec filter_only_approved([Shuttle.t()], boolean()) :: [Shuttle.t()] + defp filter_only_approved(shuttles, false), do: shuttles + + defp filter_only_approved(shuttles, true) do + Enum.filter(shuttles, fn shuttle -> shuttle.status == :active end) + end end diff --git a/lib/arrow_web/controllers/disruption_v2_controller.ex b/lib/arrow_web/controllers/disruption_v2_controller.ex index f4dad9e89..431ceeebc 100644 --- a/lib/arrow_web/controllers/disruption_v2_controller.ex +++ b/lib/arrow_web/controllers/disruption_v2_controller.ex @@ -1,8 +1,7 @@ defmodule ArrowWeb.DisruptionV2Controller do use ArrowWeb, :controller - alias Arrow.Disruptions - alias ArrowWeb.DisruptionV2Controller.Filters + alias ArrowWeb.DisruptionV2Controller.{Filters, Index} alias ArrowWeb.Plug.Authorize alias Plug.Conn @@ -12,9 +11,11 @@ defmodule ArrowWeb.DisruptionV2Controller do def index(conn, params) do filters = Filters.from_params(params) + disruptions = Index.all(filters) + render(conn, "index.html", user: conn.assigns.current_user, - disruptions: Disruptions.list_disruptionsv2(), + disruptions: disruptions, filters: filters ) end diff --git a/lib/arrow_web/controllers/disruption_v2_controller/filters.ex b/lib/arrow_web/controllers/disruption_v2_controller/filters.ex index 680ae15e6..39b3e31a2 100644 --- a/lib/arrow_web/controllers/disruption_v2_controller/filters.ex +++ b/lib/arrow_web/controllers/disruption_v2_controller/filters.ex @@ -20,7 +20,11 @@ defmodule ArrowWeb.DisruptionV2Controller.Filters do @behaviour Behaviour - @type t :: %__MODULE__{view: Calendar.t() | Table.t()} + @type t :: %__MODULE__{ + kinds: MapSet.t(atom()), + only_approved?: boolean(), + view: Calendar.t() | Table.t() + } defstruct kinds: @empty_set, only_approved?: false, search: nil, view: %Table{} @@ -37,7 +41,12 @@ defmodule ArrowWeb.DisruptionV2Controller.Filters do def from_params(params) when is_map(params) do view_mod = if(params["view"] == "calendar", do: Calendar, else: Table) - %__MODULE__{view: view_mod.from_params(params)} + %__MODULE__{ + kinds: + params |> Map.get("kinds", []) |> Enum.map(&String.to_existing_atom/1) |> MapSet.new(), + only_approved?: not is_nil(params["only_approved"]), + view: view_mod.from_params(params) + } end @impl true @@ -50,16 +59,35 @@ defmodule ArrowWeb.DisruptionV2Controller.Filters do %__MODULE__{view: view_mod.reset(view)} end + @spec toggle_kind(%__MODULE__{}, atom()) :: %__MODULE__{} + def toggle_kind(%__MODULE__{kinds: kinds} = filters, kind) do + new_kinds = if(kind in kinds, do: MapSet.delete(kinds, kind), else: MapSet.put(kinds, kind)) + struct!(filters, kinds: new_kinds) + end + + @spec toggle_only_approved(t()) :: t() + def toggle_only_approved(%__MODULE__{only_approved?: only_approved} = filters) do + %__MODULE__{filters | only_approved?: !only_approved} + end + @spec toggle_view(%__MODULE__{}) :: %__MODULE__{} def toggle_view(%__MODULE__{view: %Calendar{}} = filters), do: %{filters | view: %Table{}} def toggle_view(%__MODULE__{view: %Table{}} = filters), do: %{filters | view: %Calendar{}} @impl true def to_params(%__MODULE__{ + kinds: kinds, + only_approved?: only_approved?, view: %{__struct__: view_mod} = view }) do %{} |> put_if(view_mod == Calendar, "view", "calendar") + |> put_if( + kinds != @empty_set, + "kinds", + kinds |> MapSet.to_list() |> Enum.map(&to_string/1) |> Enum.sort() + ) + |> put_if(only_approved?, "only_approved", "true") |> Map.merge(view_mod.to_params(view)) end diff --git a/lib/arrow_web/controllers/disruption_v2_controller/index.ex b/lib/arrow_web/controllers/disruption_v2_controller/index.ex new file mode 100644 index 000000000..29f86309f --- /dev/null +++ b/lib/arrow_web/controllers/disruption_v2_controller/index.ex @@ -0,0 +1,62 @@ +defmodule ArrowWeb.DisruptionV2Controller.Index do + @moduledoc """ + Applies filters on disruptions for index view + """ + + alias Arrow.Disruptions + alias Arrow.Disruptions.DisruptionV2 + alias ArrowWeb.DisruptionV2Controller.Filters + alias ArrowWeb.DisruptionV2Controller.Filters.Table + + @disruption_kind_routes %{ + blue_line: ["Blue"], + orange_line: ["Orange"], + red_line: ["Red"], + mattapan_line: ["Mattapan"], + green_line: ["Green-B", "Green-C", "Green-D", "Green-E"], + green_line_b: ["Green-B"], + green_line_c: ["Green-C"], + green_line_d: ["Green-D"], + green_line_e: ["Green-E"] + } + + @empty_set MapSet.new() + + @spec all(Filters.t() | nil) :: [DisruptionV2.t()] + def all(filters), + do: apply_to_disruptions(Disruptions.list_disruptionsv2(), filters) + + @spec apply_to_disruptions([DisruptionV2.t()], Filters.t()) :: [DisruptionV2.t()] + def apply_to_disruptions(disruptions, filters) do + Enum.filter( + disruptions, + &(apply_kinds_filter(&1, filters) and apply_only_approved_filter(&1, filters) and + apply_past_filter(&1, filters)) + ) + end + + defp apply_kinds_filter(_disruption, %Filters{kinds: kinds}) when kinds == @empty_set, + do: true + + defp apply_kinds_filter(disruption, %Filters{kinds: kinds}) do + kind_routes = kinds |> Enum.map(&@disruption_kind_routes[&1]) |> List.flatten() + + Enum.any?(disruption.limits, fn limit -> limit.route.id in kind_routes end) + end + + defp apply_only_approved_filter(disruption, %Filters{only_approved?: true}), + do: disruption.is_active + + defp apply_only_approved_filter(_disruption, %Filters{only_approved?: false}), + do: true + + defp apply_past_filter(disruption, %Filters{view: %Table{include_past?: false}}) do + cutoff = Date.utc_today() |> Date.add(-7) + + {_start_date, end_date} = Disruptions.start_end_dates(disruption) + + is_nil(end_date) or Date.after?(end_date, cutoff) + end + + defp apply_past_filter(_disruption, _filter), do: true +end diff --git a/lib/arrow_web/controllers/disruption_v2_html.ex b/lib/arrow_web/controllers/disruption_v2_html.ex index f749b09ef..754921b4f 100644 --- a/lib/arrow_web/controllers/disruption_v2_html.ex +++ b/lib/arrow_web/controllers/disruption_v2_html.ex @@ -2,6 +2,7 @@ defmodule ArrowWeb.DisruptionV2View do use ArrowWeb, :html alias __MODULE__.Calendar, as: DCalendar + alias Arrow.Disruptions alias Arrow.Disruptions.DisruptionV2 alias Arrow.Permissions alias ArrowWeb.DisruptionV2Controller.Filters @@ -20,6 +21,36 @@ defmodule ArrowWeb.DisruptionV2View do "Red" => "red-line" } + @disruption_kinds ~w( + blue_line + orange_line + red_line + mattapan_line + green_line + green_line_b + green_line_c + green_line_d + green_line_e + commuter_rail + silver_line + bus + )a + + @disruption_kind_icon_names %{ + blue_line: "blue-line", + bus: "mode-bus", + commuter_rail: "mode-commuter-rail", + green_line: "green-line", + green_line_b: "green-line-b", + green_line_c: "green-line-c", + green_line_d: "green-line-d", + green_line_e: "green-line-e", + mattapan_line: "mattapan-line", + orange_line: "orange-line", + red_line: "red-line", + silver_line: "silver-line" + } + attr :conn, Plug.Conn, required: true attr :route_id, :string, required: true attr :size, :string, values: ~w(sm lg), required: true @@ -39,29 +70,22 @@ defmodule ArrowWeb.DisruptionV2View do Routes.static_path(conn, "/images/icon-#{@route_icon_names[route_id]}-small.svg") end - defp disrupted_routes(%DisruptionV2{limits: limits}) do - limits |> Enum.map(& &1.route.id) |> Enum.uniq() + @spec disruption_kind_icon_path(Plug.Conn.t(), atom()) :: String.t() + def disruption_kind_icon_path(conn, kind) do + Routes.static_path(conn, "/images/icon-#{@disruption_kind_icon_names[kind]}-small.svg") end - defp get_dates(%DisruptionV2{limits: [], replacement_services: []}) do - {nil, nil} + defp disruption_kinds, do: @disruption_kinds + + defp disruption_kind_icon(conn, kind, size, opts \\ []) when size in ~w(sm lg) do + content_tag(:span, "", + class: "m-icon m-icon-#{size} #{Keyword.get(opts, :class, "")}", + style: "background-image: url(#{disruption_kind_icon_path(conn, kind)})" + ) end - defp get_dates(%DisruptionV2{ - limits: limits, - replacement_services: replacement_services - }) do - min_date = - (limits ++ replacement_services) - |> Enum.map(& &1.start_date) - |> Enum.min(Date, fn -> ~D[9999-12-31] end) - - max_date = - (limits ++ replacement_services) - |> Enum.map(& &1.end_date) - |> Enum.max(Date, fn -> ~D[0000-01-01] end) - - {min_date, max_date} + defp disrupted_routes(%DisruptionV2{limits: limits}) do + limits |> Enum.map(& &1.route.id) |> Enum.uniq() end defp format_date(nil), do: "N/A" @@ -73,4 +97,8 @@ defmodule ArrowWeb.DisruptionV2View do defp update_filters_path(conn, filters) do Controller.current_path(conn, Filters.to_params(filters)) end + + defp update_view_path(conn, %{view: view} = filters, key, value) do + update_filters_path(conn, %{filters | view: %{view | key => value}}) + end end diff --git a/lib/arrow_web/controllers/disruption_v2_html/_table.html.heex b/lib/arrow_web/controllers/disruption_v2_html/_table.html.heex index e71571509..292b1fb07 100644 --- a/lib/arrow_web/controllers/disruption_v2_html/_table.html.heex +++ b/lib/arrow_web/controllers/disruption_v2_html/_table.html.heex @@ -14,7 +14,7 @@ <%= for disruption <- @disruptions do %> - <% {start_date, end_date} = get_dates(disruption) %> + <% {start_date, end_date} = Disruptions.start_end_dates(disruption) %> diff --git a/lib/arrow_web/controllers/disruption_v2_html/index.html.heex b/lib/arrow_web/controllers/disruption_v2_html/index.html.heex index 6627b9a9f..d2bfcb4d2 100644 --- a/lib/arrow_web/controllers/disruption_v2_html/index.html.heex +++ b/lib/arrow_web/controllers/disruption_v2_html/index.html.heex @@ -15,7 +15,36 @@
-
+
+ <%= for kind <- disruption_kinds() do %> + <% show_as_active? = MapSet.size(@filters.kinds) == 0 or kind in @filters.kinds %> + <% active_class = if(show_as_active?, do: "active", else: "") %> + + <.link + class={"d-flex mr-1 m-disruption-index__route_filter #{active_class}"} + aria-label={kind |> to_string() |> String.replace("_", " ")} + href={update_filters_path(@conn, Filters.toggle_kind(@filters, kind))} + > + {disruption_kind_icon(@conn, kind, "lg")} + + <% end %> + + <%= if not Filters.calendar?(@filters) do %> + {link("include past", + class: + "mx-2 btn btn-outline-secondary" <> + if(@filters.view.include_past?, do: " active", else: ""), + to: update_view_path(@conn, @filters, :include_past?, !@filters.view.include_past?) + )} + <% end %> + + {link("approved", + class: + "mx-2 btn btn-outline-secondary" <> + if(@filters.only_approved?, do: " active", else: ""), + to: update_filters_path(@conn, Filters.toggle_only_approved(@filters)) + )} + {link("⬒ #{if(Filters.calendar?(@filters), do: "list", else: "calendar")} view", class: "ml-auto btn btn-outline-secondary", to: update_filters_path(@conn, Filters.toggle_view(@filters)) diff --git a/test/arrow/shuttle/shuttle_test.exs b/test/arrow/shuttle/shuttle_test.exs index 9cae1b49d..7bc7354a5 100644 --- a/test/arrow/shuttle/shuttle_test.exs +++ b/test/arrow/shuttle/shuttle_test.exs @@ -121,5 +121,119 @@ defmodule Arrow.Shuttles.ShuttleTest do assert %Ecto.Changeset{valid?: true} = changeset end + + test "cannot mark a shuttle as inactive when in use by a replacement service" do + shuttle = shuttle_fixture() + [route0, route1] = shuttle.routes + + [stop1, stop2, stop3, stop4] = insert_list(4, :gtfs_stop) + + route0 + |> Arrow.Shuttles.Route.changeset(%{ + "route_stops" => [ + %{ + "direction_id" => "0", + "stop_sequence" => "1", + "display_stop_id" => stop1.id, + "time_to_next_stop" => 30.0 + }, + %{ + "direction_id" => "0", + "stop_sequence" => "2", + "display_stop_id" => stop2.id + } + ] + }) + |> Arrow.Repo.update() + + route1 + |> Arrow.Shuttles.Route.changeset(%{ + "route_stops" => [ + %{ + "direction_id" => "1", + "stop_sequence" => "1", + "display_stop_id" => stop3.id, + "time_to_next_stop" => 30.0 + }, + %{ + "direction_id" => "0", + "stop_sequence" => "2", + "display_stop_id" => stop4.id + } + ] + }) + |> Arrow.Repo.update() + + {:ok, shuttle} = + shuttle.id + |> Arrow.Shuttles.get_shuttle!() + |> Shuttle.changeset(%{status: :active}) + |> Arrow.Repo.update() + + insert(:replacement_service, shuttle: shuttle) + + changeset = Shuttle.changeset(shuttle, %{status: :draft}) + + assert %Ecto.Changeset{ + valid?: false, + errors: [ + status: + {"cannot set to a non-active status while in use as a replacement service", []} + ] + } = changeset + end + + test "can mark a shuttle as inactive when not in use by a replacement service" do + shuttle = shuttle_fixture() + [route0, route1] = shuttle.routes + + [stop1, stop2, stop3, stop4] = insert_list(4, :gtfs_stop) + + route0 + |> Arrow.Shuttles.Route.changeset(%{ + "route_stops" => [ + %{ + "direction_id" => "0", + "stop_sequence" => "1", + "display_stop_id" => stop1.id, + "time_to_next_stop" => 30.0 + }, + %{ + "direction_id" => "0", + "stop_sequence" => "2", + "display_stop_id" => stop2.id + } + ] + }) + |> Arrow.Repo.update() + + route1 + |> Arrow.Shuttles.Route.changeset(%{ + "route_stops" => [ + %{ + "direction_id" => "1", + "stop_sequence" => "1", + "display_stop_id" => stop3.id, + "time_to_next_stop" => 30.0 + }, + %{ + "direction_id" => "0", + "stop_sequence" => "2", + "display_stop_id" => stop4.id + } + ] + }) + |> Arrow.Repo.update() + + {:ok, shuttle} = + shuttle.id + |> Arrow.Shuttles.get_shuttle!() + |> Shuttle.changeset(%{status: :active}) + |> Arrow.Repo.update() + + changeset = Shuttle.changeset(shuttle, %{status: :draft}) + + assert %Ecto.Changeset{valid?: true} = changeset + end end end diff --git a/test/arrow_web/controllers/disruption_v2_controller/filters_test.exs b/test/arrow_web/controllers/disruption_v2_controller/filters_test.exs new file mode 100644 index 000000000..7b3b41ed9 --- /dev/null +++ b/test/arrow_web/controllers/disruption_v2_controller/filters_test.exs @@ -0,0 +1,144 @@ +defmodule ArrowWeb.DisruptionV2Controller.FiltersTest do + use ExUnit.Case, async: true + + alias ArrowWeb.DisruptionV2Controller.Filters + alias ArrowWeb.DisruptionV2Controller.Filters.{Calendar, Table} + + defp set(items \\ []), do: MapSet.new(items) + + describe "from_params/1 and to_params/1" do + import Filters, only: [from_params: 1, to_params: 1] + + defp assert_equivalent(params, struct) do + assert from_params(params) == struct + assert to_params(struct) == params + end + + test "table view with default filters is an empty map" do + assert_equivalent(%{}, %Filters{view: %Table{}}) + end + + test "calendar view is indicated with a param" do + assert_equivalent(%{"view" => "calendar"}, %Filters{view: %Calendar{}}) + end + + test "kinds are indicated with a list param if not empty" do + assert_equivalent( + %{"kinds" => ["blue_line", "red_line"]}, + %Filters{kinds: set(~w(red_line blue_line)a)} + ) + + assert from_params(%{"kinds" => []}) == %Filters{kinds: set()} + assert to_params(%Filters{kinds: set()}) == %{} + end + + test "table view: include_past is indicated with a param if true" do + assert_equivalent(%{"include_past" => "true"}, %Filters{view: %Table{include_past?: true}}) + + assert from_params(%{"include_past" => "abc"}) == %Filters{ + view: %Table{include_past?: true} + } + + assert from_params(%{"include_past" => nil}) == %Filters{view: %Table{include_past?: false}} + assert to_params(%Filters{view: %Table{include_past?: false}}) == %{} + end + + test "table view: sort has a default and can be expressed as ascending or descending" do + assert_equivalent(%{}, %Filters{view: %Table{sort: {:asc, :start_date}}}) + assert_equivalent(%{"sort" => "id"}, %Filters{view: %Table{sort: {:asc, :id}}}) + assert_equivalent(%{"sort" => "-id"}, %Filters{view: %Table{sort: {:desc, :id}}}) + end + end + + describe "calendar?/1" do + test "indicates whether the calendar view is active" do + assert Filters.calendar?(%Filters{view: %Calendar{}}) + refute Filters.calendar?(%Filters{view: %Table{}}) + end + end + + describe "flatten/1" do + test "flattens base and view-specific filters into a map" do + kinds = set(~w(commuter_rail silver_line)) + calendar_filters = %Filters{kinds: kinds, search: "test", view: %Calendar{}} + table_filters = %{calendar_filters | view: %Table{include_past?: true, sort: {:asc, :id}}} + + assert Filters.flatten(calendar_filters) == %{ + kinds: kinds, + only_approved?: false, + search: "test" + } + + table_expected = %{ + kinds: kinds, + search: "test", + include_past?: true, + only_approved?: false, + sort: {:asc, :id} + } + + assert Filters.flatten(table_filters) == table_expected + end + end + + describe "resettable?/1" do + test "is true if any base or view-specific filters do not have their default values" do + refute Filters.resettable?(%Filters{}) + refute Filters.resettable?(%Filters{view: %Calendar{}}) + assert Filters.resettable?(%Filters{search: "test"}) + assert Filters.resettable?(%Filters{view: %Table{include_past?: true}}) + assert Filters.resettable?(%Filters{only_approved?: true}) + end + + test "does not treat the sort field of the table view as resettable" do + refute Filters.resettable?(%Filters{view: %Table{sort: {:asc, :something}}}) + end + end + + describe "reset/1" do + test "resets filters to their default values without changing the view" do + filters = %Filters{ + search: "test", + kinds: set(~w(red_line)), + only_approved?: true, + view: %Table{include_past?: true} + } + + assert Filters.reset(filters) == %Filters{} + assert Filters.reset(%{filters | view: %Calendar{}}) == %Filters{view: %Calendar{}} + end + + test "does not reset the sort field of the table view" do + filters = %Filters{view: %Table{sort: {:asc, :something}}} + assert Filters.reset(filters) == filters + end + end + + describe "toggle_kind/2" do + test "adds the given kind to the kinds filter if it is not present" do + filters = %Filters{kinds: set(~w(red_line)a)} + assert Filters.toggle_kind(filters, :bus) == %Filters{kinds: set(~w(red_line bus)a)} + end + + test "removes the given kind from the kinds filter if it is present" do + filters = %Filters{kinds: set(~w(red_line blue_line)a)} + assert Filters.toggle_kind(filters, :red_line) == %Filters{kinds: set(~w(blue_line)a)} + end + end + + describe "toggle_view/1" do + test "toggles the active view between Calendar and Table" do + assert Filters.toggle_view(%Filters{view: %Calendar{}}) == %Filters{view: %Table{}} + assert Filters.toggle_view(%Filters{view: %Table{}}) == %Filters{view: %Calendar{}} + end + end + + describe "to_flat_params/1" do + test "functions as to_params/1 but flattens lists into query-param format" do + filters = %Filters{search: "test", kinds: set(~w(red_line blue_line)a)} + + expected = [{"kinds[]", "blue_line"}, {"kinds[]", "red_line"}] + assert Filters.to_flat_params(filters) == expected + end + end +end diff --git a/test/arrow_web/controllers/disruption_v2_controller_test.exs b/test/arrow_web/controllers/disruption_v2_controller_test.exs new file mode 100644 index 000000000..db0661943 --- /dev/null +++ b/test/arrow_web/controllers/disruption_v2_controller_test.exs @@ -0,0 +1,67 @@ +defmodule ArrowWeb.DisruptionV2ControllerTest do + use ArrowWeb.ConnCase + + import Arrow.Factory + + describe "index/2" do + @tag :authenticated + test "lists disruptions", %{conn: conn} do + insert(:limit, + disruption: build(:disruption_v2, title: "Test disruption"), + route: build(:gtfs_route, id: "Red") + ) + + resp = conn |> get(~p"/disruptionsv2") |> html_response(200) + + assert resp =~ "Test disruption" + end + + @tag :authenticated + test "lists disruptions that match a route filter", %{conn: conn} do + insert(:limit, + disruption: build(:disruption_v2, title: "Test disruption"), + route: build(:gtfs_route, id: "Red") + ) + + resp = conn |> get(~p"/disruptionsv2?kinds[]=red_line") |> html_response(200) + + assert resp =~ "Test disruption" + end + + @tag :authenticated + test "doesn't list disruptions that don't match a route filter", %{conn: conn} do + insert(:limit, + disruption: build(:disruption_v2, title: "Test disruption"), + route: build(:gtfs_route, id: "Red") + ) + + resp = conn |> get(~p"/disruptionsv2?kinds[]=orange_line") |> html_response(200) + + refute resp =~ "Test disruption" + end + + @tag :authenticated + test "lists disruptions that satisfy the only approved filter", %{conn: conn} do + insert(:limit, + disruption: build(:disruption_v2, title: "Test disruption", is_active: true), + route: build(:gtfs_route, id: "Red") + ) + + resp = conn |> get(~p"/disruptionsv2?only_approved=true") |> html_response(200) + + assert resp =~ "Test disruption" + end + + @tag :authenticated + test "doesn't list disruptions that don't satisfy the only approved filter", %{conn: conn} do + insert(:limit, + disruption: build(:disruption_v2, title: "Test disruption", is_active: false), + route: build(:gtfs_route, id: "Red") + ) + + resp = conn |> get(~p"/disruptionsv2?only_approved=true") |> html_response(200) + + refute resp =~ "Test disruption" + end + end +end diff --git a/test/integration/disruptions_v2_test.exs b/test/integration/disruptions_v2_test.exs index c53542533..6870eac72 100644 --- a/test/integration/disruptions_v2_test.exs +++ b/test/integration/disruptions_v2_test.exs @@ -2,6 +2,7 @@ defmodule Arrow.Integration.DisruptionsV2Test do use ExUnit.Case, async: true use Wallaby.Feature import Wallaby.Browser, except: [text: 1] + import Wallaby.Query import Arrow.{DisruptionsFixtures, LimitsFixtures} @moduletag :integration @@ -15,6 +16,7 @@ defmodule Arrow.Integration.DisruptionsV2Test do session |> visit("/disruptionsv2") + |> click(link("include past")) |> assert_text(disruption.title) |> assert_text("01/01/24") |> assert_text("01/01/25")