+
+ <%= 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")