From d0614865b86a71ebc7c798f6d742bd94de3a46e1 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 12 Sep 2024 12:17:38 -0400 Subject: [PATCH] feat: Activate detour flow --- .../detours/activateDetourModal.tsx | 156 +++++++++ .../detours/detourFinishedPanel.tsx | 18 +- .../src/components/detours/diversionPage.tsx | 79 ++++- assets/src/hooks/useDetour.ts | 11 + assets/src/models/createDetourMachine.ts | 133 ++++++- .../detours/diversionPage.activate.test.tsx | 328 ++++++++++++++++-- 6 files changed, 685 insertions(+), 40 deletions(-) create mode 100644 assets/src/components/detours/activateDetourModal.tsx diff --git a/assets/src/components/detours/activateDetourModal.tsx b/assets/src/components/detours/activateDetourModal.tsx new file mode 100644 index 000000000..2f4d5c6bf --- /dev/null +++ b/assets/src/components/detours/activateDetourModal.tsx @@ -0,0 +1,156 @@ +import React, { PropsWithChildren } from "react" +import { Button, Form, Modal } from "react-bootstrap" + +const possibleDurations = [ + "1 hour", + "2 hours", + "3 hours", + "4 hours", + "5 hours", + "6 hours", + "7 hours", + "8 hours", + "Until end of service", + "Until further notice", +] + +const possibleReasons = [ + "Accident", + "Construction", + "Demonstration", + "Disabled bus", + "Drawbridge being raised", + "Electrical work", + "Fire", + "Hazmat condition", + "Holiday", + "Hurricane", + "Maintenance", + "Medical emergency", + "Parade", + "Police activity", + "Snow", + "Special event", + "Tie replacement", + "Traffic", + "Weather", +] + +interface SurroundingModalProps extends PropsWithChildren { + onCancel: () => void + onNext?: () => void + onBack?: () => void + onActivate?: () => void +} + +const SurroundingModal = ({ + onCancel, + onNext, + onBack, + onActivate, + children, +}: SurroundingModalProps) => ( + + Start detour + {children} + + {onBack && ( + + )} + + {onActivate ? ( + + ) : ( + + )} + + +) + +const SelectingDuration = ({ + onSelectDuration, + selectedDuration, +}: { + onSelectDuration: (duration: string) => void + selectedDuration?: string +}) => ( + +
Step 1 of 3 - Select detour duration
+

+ Time length (estimate) +

+
+ {possibleDurations.map((duration) => ( + { + onSelectDuration(duration) + }} + id={`duration-${duration}`} + key={`duration-${duration}`} + type="radio" + label={duration} + checked={selectedDuration === duration} + /> + ))} + +
+) + +const SelectingReason = ({ + onSelectReason, + selectedReason, +}: { + onSelectReason: (reason: string) => void + selectedReason?: string +}) => ( + +
Step 2 of 3 - Select reason for detour
+
+ {possibleReasons.map((reason) => ( + { + onSelectReason(reason) + }} + id={`reason-${reason}`} + key={`reason-${reason}`} + type="radio" + label={reason} + checked={selectedReason === reason} + /> + ))} + +
+) + +const Confirming = () => ( + +
Step 3 of 3 - Activate detour
+

Are you sure that you want to activate this detour?

+

+ Once activated, other Skate users, OIOs, and MBTA ridership will see this + detour information. +

+

+ You will still need to radio people / whatever is accurate. +

+
+) + +export const ActivateDetour = { + Modal: SurroundingModal, + SelectingDuration, + SelectingReason, + Confirming, +} diff --git a/assets/src/components/detours/detourFinishedPanel.tsx b/assets/src/components/detours/detourFinishedPanel.tsx index efe159823..c993b1f81 100644 --- a/assets/src/components/detours/detourFinishedPanel.tsx +++ b/assets/src/components/detours/detourFinishedPanel.tsx @@ -1,19 +1,22 @@ -import React from "react" +import React, { PropsWithChildren } from "react" import { Button, Form, OverlayTrigger, Popover } from "react-bootstrap" import * as BsIcons from "../../helpers/bsIcons" import { Panel } from "./diversionPage" +interface DetourFinishedPanelProps extends PropsWithChildren { + onNavigateBack: () => void + detourText: string + onChangeDetourText: (value: string) => void + onActivateDetour?: () => void +} + export const DetourFinishedPanel = ({ onNavigateBack, detourText, onChangeDetourText, onActivateDetour, -}: { - onNavigateBack: () => void - detourText: string - onChangeDetourText: (value: string) => void - onActivateDetour?: () => void -}) => ( + children, +}: DetourFinishedPanelProps) => (

Share Detour Details

@@ -71,5 +74,6 @@ export const DetourFinishedPanel = ({ )} + {children}
) diff --git a/assets/src/components/detours/diversionPage.tsx b/assets/src/components/detours/diversionPage.tsx index b4e506e46..df73b52ff 100644 --- a/assets/src/components/detours/diversionPage.tsx +++ b/assets/src/components/detours/diversionPage.tsx @@ -23,6 +23,7 @@ import inTestGroup, { TestGroups } from "../../userInTestGroup" import { ActiveDetourPanel } from "./activeDetourPanel" import { PastDetourPanel } from "./pastDetourPanel" import userInTestGroup from "../../userInTestGroup" +import { ActivateDetour } from "./activateDetourModal" const displayFieldsFromRouteAndPattern = ( route: Route, @@ -100,6 +101,9 @@ export const DiversionPage = ({ clear, reviewDetour, editDetour, + + selectedDuration, + selectedReason, } = useDetour( "snapshot" in useDetourProps ? { snapshot: useDetourProps.snapshot } @@ -256,11 +260,82 @@ export const DiversionPage = ({ onActivateDetour={ inTestGroup(TestGroups.DetoursList) ? () => { - send({ type: "detour.share.activate" }) + send({ type: "detour.share.open-activate-modal" }) } : undefined } - /> + > + {snapshot.matches({ + "Detour Drawing": { + "Share Detour": "Activating", + }, + }) ? ( + { + send({ type: "detour.share.activate-modal.cancel" }) + }} + onBack={ + snapshot.can({ type: "detour.share.activate-modal.back" }) + ? () => { + send({ type: "detour.share.activate-modal.back" }) + } + : undefined + } + onNext={ + snapshot.can({ type: "detour.share.activate-modal.next" }) + ? () => { + send({ type: "detour.share.activate-modal.next" }) + } + : undefined + } + onActivate={ + snapshot.can({ + type: "detour.share.activate-modal.activate", + }) + ? () => { + send({ type: "detour.share.activate-modal.activate" }) + } + : undefined + } + > + {snapshot.matches({ + "Detour Drawing": { + "Share Detour": { Activating: "Selecting Duration" }, + }, + }) ? ( + { + send({ + type: "detour.share.activate-modal.select-duration", + duration: selectedDuration, + }) + }} + selectedDuration={selectedDuration} + /> + ) : snapshot.matches({ + "Detour Drawing": { + "Share Detour": { Activating: "Selecting Reason" }, + }, + }) ? ( + { + send({ + type: "detour.share.activate-modal.select-reason", + reason: selectedReason, + }) + }} + selectedReason={selectedReason} + /> + ) : snapshot.matches({ + "Detour Drawing": { + "Share Detour": { Activating: "Confirming" }, + }, + }) ? ( + + ) : null} + + ) : null} + ) : snapshot.matches({ "Detour Drawing": "Active" }) ? ( { finishedDetour, detourShape, nearestIntersection, + selectedDuration, + selectedReason, } = snapshot.context const { result: unfinishedDetour } = useApiCall({ @@ -213,5 +215,14 @@ export const useDetour = (input: UseDetourInput) => { : undefined, /** When present, puts this detour in "edit mode" */ editDetour, + + /** + * Detour duration as selected in the activate-detour flow + */ + selectedDuration, + /** + * Detour reason as selected in the activate-detour flow + */ + selectedReason, } } diff --git a/assets/src/models/createDetourMachine.ts b/assets/src/models/createDetourMachine.ts index b97779c00..09c90751f 100644 --- a/assets/src/models/createDetourMachine.ts +++ b/assets/src/models/createDetourMachine.ts @@ -29,6 +29,9 @@ export const createDetourMachine = setup({ detourShape: Result | undefined finishedDetour: FinishedDetour | undefined | null + + selectedDuration?: string + selectedReason?: string }, input: {} as @@ -64,7 +67,19 @@ export const createDetourMachine = setup({ | { type: "detour.edit.place-waypoint"; location: ShapePoint } | { type: "detour.edit.undo" } | { type: "detour.share.copy-detour"; detourText: string } - | { type: "detour.share.activate" } + | { type: "detour.share.open-activate-modal" } + | { + type: "detour.share.activate-modal.select-duration" + duration: string + } + | { + type: "detour.share.activate-modal.select-reason" + reason: string + } + | { type: "detour.share.activate-modal.next" } + | { type: "detour.share.activate-modal.cancel" } + | { type: "detour.share.activate-modal.back" } + | { type: "detour.share.activate-modal.activate" } | { type: "detour.active.deactivate" } | { type: "detour.save.begin-save" } | { type: "detour.save.set-uuid"; uuid: number }, @@ -458,6 +473,7 @@ export const createDetourMachine = setup({ }, }, "Share Detour": { + initial: "Reviewing", on: { "detour.edit.resume": { target: "Editing.Finished Drawing", @@ -466,6 +482,121 @@ export const createDetourMachine = setup({ target: "Active", }, }, + states: { + Reviewing: { + on: { + "detour.share.open-activate-modal": { + target: "Activating", + }, + }, + }, + Activating: { + initial: "Selecting Duration", + on: { + "detour.share.activate-modal.cancel": { + target: "Reviewing", + }, + }, + states: { + "Selecting Duration": { + initial: "Begin", + states: { + Begin: { + always: [ + { + guard: ({ context: { selectedDuration } }) => + selectedDuration === undefined, + target: "No Duration Selected", + }, + { target: "Duration Selected" }, + ], + }, + "No Duration Selected": {}, + "Duration Selected": { + on: { + "detour.share.activate-modal.next": { + target: "Done", + }, + }, + }, + Done: { + type: "final", + }, + }, + + on: { + "detour.share.activate-modal.select-duration": { + target: "Selecting Duration", + actions: assign({ + selectedDuration: ({ event }) => event.duration, + }), + }, + }, + onDone: { + target: "Selecting Reason", + }, + }, + "Selecting Reason": { + initial: "Begin", + states: { + Begin: { + always: [ + { + guard: ({ context: { selectedReason } }) => + selectedReason === undefined, + target: "No Reason Selected", + }, + { target: "Reason Selected" }, + ], + }, + "No Reason Selected": {}, + "Reason Selected": { + on: { + "detour.share.activate-modal.next": { + target: "Done", + }, + }, + }, + Done: { + type: "final", + }, + }, + on: { + "detour.share.activate-modal.back": { + target: "Selecting Duration", + }, + "detour.share.activate-modal.select-reason": { + target: "Selecting Reason", + actions: assign({ + selectedReason: ({ event }) => event.reason, + }), + }, + }, + onDone: { + target: "Confirming", + }, + }, + Confirming: { + on: { + "detour.share.activate-modal.back": { + target: "Selecting Reason", + }, + "detour.share.activate-modal.activate": { + target: "Done", + }, + }, + }, + Done: { type: "final" }, + }, + onDone: { + target: "Done", + }, + }, + Done: { type: "final" }, + }, + onDone: { + target: "Active", + }, }, Active: { on: { diff --git a/assets/tests/components/detours/diversionPage.activate.test.tsx b/assets/tests/components/detours/diversionPage.activate.test.tsx index e979e7167..c641b9868 100644 --- a/assets/tests/components/detours/diversionPage.activate.test.tsx +++ b/assets/tests/components/detours/diversionPage.activate.test.tsx @@ -24,6 +24,7 @@ import { putDetourUpdate, } from "../../../src/api" import { neverPromise } from "../../testHelpers/mockHelpers" +import { byRole } from "testing-library-selector" beforeEach(() => { jest.spyOn(global, "scrollTo").mockImplementationOnce(jest.fn()) @@ -70,53 +71,320 @@ const diversionPageOnReviewScreen = async ( return { container } } +const diversionPageOnSelectDurationModalScreen = async ( + props?: Partial +) => { + const { container } = await diversionPageOnReviewScreen(props) + + await userEvent.click(activateDetourButton.get()) + + return { container } +} + +const diversionPageOnSelectReasonModalScreen = async ( + props?: Partial +) => { + const { container } = await diversionPageOnSelectDurationModalScreen(props) + + await userEvent.click(threeHoursRadio.get()) + await userEvent.click(nextButton.get()) + + return { container } +} + +const diversionPageOnConfirmModalScreen = async ( + props?: Partial +) => { + const { container } = await diversionPageOnSelectReasonModalScreen(props) + + await userEvent.click(constructionRadio.get()) + await userEvent.click(nextButton.get()) + + return { container } +} + +const diversionPageOnActiveDetourScreen = async ( + props?: Partial +) => { + const { container } = await diversionPageOnConfirmModalScreen(props) + + await userEvent.click(activateButton.get()) + + return { container } +} + +const step1Heading = byRole("heading", { + name: "Step 1 of 3 - Select detour duration", +}) +const step2Heading = byRole("heading", { + name: "Step 2 of 3 - Select reason for detour", +}) +const step3Heading = byRole("heading", { + name: "Step 3 of 3 - Activate detour", +}) + +const backButton = byRole("button", { name: "Back" }) +const cancelButton = byRole("button", { name: "Cancel" }) +const nextButton = byRole("button", { name: "Next" }) +const activateButton = byRole("button", { name: "Activate detour" }) + +const threeHoursRadio = byRole("radio", { name: "3 hours" }) +const oneHourRadio = byRole("radio", { name: "1 hour" }) + +const constructionRadio = byRole("radio", { name: "Construction" }) +const paradeRadio = byRole("radio", { name: "Parade" }) + describe("DiversionPage activate workflow", () => { - test("does not have an activate button on the review details screen if not in the detours-list test group", async () => { - jest.mocked(getTestGroups).mockReturnValue([TestGroups.DetoursPilot]) + describe("from before the activate modal", () => { + test("does not have an activate button on the review details screen if not in the detours-list test group", async () => { + jest.mocked(getTestGroups).mockReturnValue([TestGroups.DetoursPilot]) + + await diversionPageOnReviewScreen() - await diversionPageOnReviewScreen() + expect(activateDetourButton.query()).not.toBeInTheDocument() + }) - expect(activateDetourButton.query()).not.toBeInTheDocument() + test("has an activate button on the review details screen", async () => { + await diversionPageOnReviewScreen() + + expect(activateDetourButton.get()).toBeVisible() + }) + + test("does not show the activate flow modal before clicking the activate button", async () => { + await diversionPageOnReviewScreen() + + expect( + screen.getByRole("heading", { name: "Share Detour Details" }) + ).toBeVisible() + expect(step1Heading.query()).not.toBeInTheDocument() + }) + + test("clicking the activate button shows the first screen of the activate flow modal", async () => { + await diversionPageOnReviewScreen() + + await userEvent.click(activateDetourButton.get()) + + expect( + screen.getByRole("heading", { name: "Share Detour Details" }) + ).toBeVisible() + expect(step1Heading.get()).toBeVisible() + }) }) - test("has an activate button on the review details screen", async () => { - await diversionPageOnReviewScreen() + describe("from the duration-selection screen on the activate modal", () => { + test("buttons start out in the right states on the activate flow modal", async () => { + await diversionPageOnSelectDurationModalScreen() + + expect(cancelButton.get()).toBeEnabled() + expect(nextButton.get()).toBeDisabled() + + expect(backButton.query()).not.toBeInTheDocument() + expect(activateButton.query()).not.toBeInTheDocument() + }) + + test("the 'Cancel' button closes the modal", async () => { + await diversionPageOnSelectDurationModalScreen() + + await userEvent.click(cancelButton.get()) + + expect(step1Heading.query()).not.toBeInTheDocument() + }) + + test("selecting a duration selects that radio button", async () => { + await diversionPageOnSelectDurationModalScreen() - expect(activateDetourButton.get()).toBeVisible() + await userEvent.click(threeHoursRadio.get()) + + expect(threeHoursRadio.get()).toBeChecked() + }) + + test("selecting a duration de-selects the previously-selected duration button", async () => { + await diversionPageOnSelectDurationModalScreen() + await userEvent.click(threeHoursRadio.get()) + + await userEvent.click(oneHourRadio.get()) + + expect(oneHourRadio.get()).toBeChecked() + expect(threeHoursRadio.get()).not.toBeChecked() + }) + + test("selecting a duration enables the 'Next' button", async () => { + await diversionPageOnSelectDurationModalScreen() + + await userEvent.click(threeHoursRadio.get()) + + expect(nextButton.get()).toBeEnabled() + }) + + test("the 'Next' button advances to the next screen", async () => { + await diversionPageOnSelectDurationModalScreen() + + await userEvent.click(threeHoursRadio.get()) + + await userEvent.click(nextButton.get()) + + expect(step1Heading.query()).not.toBeInTheDocument() + expect(step2Heading.get()).toBeVisible() + }) + + test("re-opening the modal after selecting an option keeps that option selected", async () => { + await diversionPageOnSelectDurationModalScreen() + + await userEvent.click(threeHoursRadio.get()) + await userEvent.click(cancelButton.get()) + + await userEvent.click(activateDetourButton.get()) + + expect(step1Heading.query()).toBeVisible() + expect(threeHoursRadio.get()).toBeChecked() + expect(nextButton.get()).toBeEnabled() + }) }) - test("clicking the activate button shows the 'Active Detour' screen", async () => { - await diversionPageOnReviewScreen() + describe("from the reason-selection screen on the activate modal", () => { + test("buttons start out in the right states on the activate flow modal", async () => { + await diversionPageOnSelectReasonModalScreen() + + expect(cancelButton.get()).toBeEnabled() + expect(nextButton.get()).toBeDisabled() + expect(backButton.get()).toBeEnabled() + }) + + test("the 'Cancel' button closes the modal", async () => { + await diversionPageOnSelectReasonModalScreen() + + await userEvent.click(cancelButton.get()) + + expect(step1Heading.query()).not.toBeInTheDocument() + expect(step2Heading.query()).not.toBeInTheDocument() + }) + + test("the 'Back' button returns to the first screen with the 'Next' button enabled", async () => { + await diversionPageOnSelectReasonModalScreen() + + await userEvent.click(backButton.get()) + + expect(step2Heading.query()).not.toBeInTheDocument() + expect(step1Heading.get()).toBeVisible() + + expect(nextButton.get()).toBeEnabled() + }) + + test("selecting a reason selects that radio button", async () => { + await diversionPageOnSelectReasonModalScreen() - await userEvent.click(activateDetourButton.get()) + await userEvent.click(constructionRadio.get()) - expect( - screen.queryByRole("heading", { name: "Share Detour Details" }) - ).not.toBeInTheDocument() - expect(screen.getByRole("heading", { name: "Active Detour" })).toBeVisible() + expect(constructionRadio.get()).toBeChecked() + }) + + test("selecting a reason de-selects the previously-selected reason button", async () => { + await diversionPageOnSelectReasonModalScreen() + await userEvent.click(constructionRadio.get()) + + await userEvent.click(paradeRadio.get()) + + expect(paradeRadio.get()).toBeChecked() + expect(constructionRadio.get()).not.toBeChecked() + }) + + test("selecting a reason enables the 'Next' button", async () => { + await diversionPageOnSelectReasonModalScreen() + + await userEvent.click(constructionRadio.get()) + + expect(nextButton.get()).toBeEnabled() + }) + + test("the 'Next' button advances to the next screen", async () => { + await diversionPageOnSelectReasonModalScreen() + + await userEvent.click(constructionRadio.get()) + + await userEvent.click(nextButton.get()) + + expect(step1Heading.query()).not.toBeInTheDocument() + expect(step2Heading.query()).not.toBeInTheDocument() + expect(step3Heading.get()).toBeVisible() + }) + + test("returning to this screen after hitting the 'Back' button leaves the option selected", async () => { + await diversionPageOnSelectReasonModalScreen() + + await userEvent.click(paradeRadio.get()) + await userEvent.click(backButton.get()) + await userEvent.click(nextButton.get()) + + expect(step2Heading.get()).toBeVisible() + + expect(paradeRadio.get()).toBeChecked() + expect(nextButton.get()).toBeEnabled() + }) }) - test("'Active Detour' screen has a 'Return to regular route' button", async () => { - await diversionPageOnReviewScreen() + describe("from the confirmation screen on the activate modal", () => { + test("buttons start out in the right states", async () => { + await diversionPageOnConfirmModalScreen() + + expect(cancelButton.get()).toBeEnabled() + expect(backButton.get()).toBeEnabled() + expect(activateButton.get()).toBeEnabled() + + expect(nextButton.query()).not.toBeInTheDocument() + }) + + test("the 'Cancel' button closes the modal", async () => { + await diversionPageOnConfirmModalScreen() + + await userEvent.click(cancelButton.get()) - await userEvent.click(activateDetourButton.get()) + expect(step1Heading.query()).not.toBeInTheDocument() + expect(step2Heading.query()).not.toBeInTheDocument() + expect(step3Heading.query()).not.toBeInTheDocument() + }) - expect( - screen.getByRole("button", { name: "Return to regular route" }) - ).toBeVisible() + test("the 'Back' button returns to the second screen", async () => { + await diversionPageOnConfirmModalScreen() + + await userEvent.click(backButton.get()) + + expect(step3Heading.query()).not.toBeInTheDocument() + expect(step2Heading.get()).toBeVisible() + }) + + test("the 'Activate' button shows the 'Active Detour' screen", async () => { + await diversionPageOnConfirmModalScreen() + + await userEvent.click(activateButton.get()) + + expect( + screen.queryByRole("heading", { name: "Share Detour Details" }) + ).not.toBeInTheDocument() + expect( + screen.getByRole("heading", { name: "Active Detour" }) + ).toBeVisible() + }) }) - test("clicking the 'Return to regular route' button shows the 'Past Detour' screen", async () => { - await diversionPageOnReviewScreen() + describe("from the 'Active Detour' screen", () => { + test("'Active Detour' screen has a 'Return to regular route' button", async () => { + await diversionPageOnActiveDetourScreen() + + expect( + screen.getByRole("button", { name: "Return to regular route" }) + ).toBeVisible() + }) - await userEvent.click(activateDetourButton.get()) + test("clicking the 'Return to regular route' button shows the 'Past Detour' screen", async () => { + await diversionPageOnActiveDetourScreen() - await userEvent.click( - screen.getByRole("button", { name: "Return to regular route" }) - ) - expect( - screen.queryByRole("heading", { name: "Active Detour" }) - ).not.toBeInTheDocument() - expect(screen.getByRole("heading", { name: "Past Detour" })).toBeVisible() + await userEvent.click( + screen.getByRole("button", { name: "Return to regular route" }) + ) + expect( + screen.queryByRole("heading", { name: "Active Detour" }) + ).not.toBeInTheDocument() + expect(screen.getByRole("heading", { name: "Past Detour" })).toBeVisible() + }) }) })