diff --git a/assets/src/components/detours/activateDetourModal.tsx b/assets/src/components/detours/activateDetourModal.tsx index 43f4ea473..3a8ac25f7 100644 --- a/assets/src/components/detours/activateDetourModal.tsx +++ b/assets/src/components/detours/activateDetourModal.tsx @@ -2,7 +2,7 @@ import React, { PropsWithChildren } from "react" import { Button, Form, Modal } from "react-bootstrap" import { StepperBar } from "../stepperBar" -const possibleDurations = [ +export const possibleDurations = [ "1 hour", "2 hours", "3 hours", @@ -42,23 +42,29 @@ interface SurroundingModalProps extends PropsWithChildren { onNext?: () => void onBack?: () => void onActivate?: () => void + nextStepButton?: string + modalTitle?: string } interface FSElementProps { nextStepLabel: string | undefined } -const SurroundingModal = ({ +export const SurroundingModal = ({ onCancel, onNext, onBack, onActivate, children, nextStepLabel, + nextStepButton, + modalTitle, }: SurroundingModalProps & FSElementProps) => ( -

Start detour

+

+ {modalTitle || "Start detour"} +

{children} @@ -85,7 +91,7 @@ const SurroundingModal = ({ onClick={onNext} data-fs-element={nextStepLabel} > - Next + {nextStepButton || "Next"} )} diff --git a/assets/src/components/detours/changeDurationModal.tsx b/assets/src/components/detours/changeDurationModal.tsx new file mode 100644 index 000000000..519003de4 --- /dev/null +++ b/assets/src/components/detours/changeDurationModal.tsx @@ -0,0 +1,46 @@ +import React from "react" +import { Form } from "react-bootstrap" +import { possibleDurations, SurroundingModal } from "./activateDetourModal" + +const ChangingDuration = ({ + onSelectDuration, + selectedDuration, + editedSelectedDuration, +}: { + onSelectDuration: (duration: string) => void + selectedDuration?: string + editedSelectedDuration?: string +}) => ( + <> + + Previous time length{" "} + (estimate) +

+ {selectedDuration} +

+
+

+ New time length (estimate) +

+
+ {possibleDurations.map((duration) => ( + { + onSelectDuration(duration) + }} + id={`duration-${duration}`} + key={`duration-${duration}`} + type="radio" + label={duration} + checked={editedSelectedDuration === duration} + /> + ))} + + +) + +export const ChangeDuration = { + Modal: SurroundingModal, + Body: ChangingDuration, +} diff --git a/assets/src/components/detours/diversionPage.tsx b/assets/src/components/detours/diversionPage.tsx index 542fb2846..57d4f2a92 100644 --- a/assets/src/components/detours/diversionPage.tsx +++ b/assets/src/components/detours/diversionPage.tsx @@ -33,6 +33,7 @@ import useScreenSize from "../../hooks/useScreenSize" import { Drawer } from "../drawer" import { isMobile } from "../../util/screenSize" import { AffectedRoute } from "./detourPanelComponents" +import { ChangeDuration } from "./changeDurationModal" import { deleteDetour } from "../../api" const displayFieldsFromRouteAndPattern = ( @@ -114,6 +115,8 @@ export const DiversionPage = ({ selectedDuration, selectedReason, + + editedSelectedDuration, } = useDetour( "snapshot" in useDetourProps ? { snapshot: useDetourProps.snapshot } @@ -447,7 +450,7 @@ export const DiversionPage = ({ userInTestGroup(TestGroups.DetoursPilot) && userInTestGroup(TestGroups.ChangeDetourDuration) ? () => { - //send({ type: "detour.active.open-change-duration-modal" }) + send({ type: "detour.active.open-change-duration-modal" }) } : undefined } @@ -471,6 +474,32 @@ export const DiversionPage = ({ routeDirection={routeDirection || "??"} /> ) : null} + {snapshot.matches({ + "Detour Drawing": { Active: "Changing Duration" }, + }) ? ( + + send({ type: "detour.active.change-duration-modal.cancel" }) + } + onNext={() => + send({ type: "detour.active.change-duration-modal.done" }) + } + nextStepButton="Done" + nextStepLabel="Confirm Duration" + modalTitle="Change detour duration" + > + { + send({ + type: "detour.active.change-duration-modal.select-duration", + duration: selectedDuration, + }) + }} + selectedDuration={selectedDuration} + editedSelectedDuration={editedSelectedDuration} + /> + + ) : null} ) } else if (snapshot.matches({ "Detour Drawing": "Past" })) { diff --git a/assets/src/hooks/useDetour.ts b/assets/src/hooks/useDetour.ts index 90761d58d..60a3c9d0e 100644 --- a/assets/src/hooks/useDetour.ts +++ b/assets/src/hooks/useDetour.ts @@ -66,6 +66,7 @@ export const useDetour = (input: UseDetourInput) => { nearestIntersection, selectedDuration, selectedReason, + editedSelectedDuration, } = snapshot.context const { result: unfinishedDetour } = useApiCall({ @@ -224,5 +225,10 @@ export const useDetour = (input: UseDetourInput) => { * Detour reason as selected in the activate-detour flow */ selectedReason, + + /** + * Detour duration while editing is in progress + */ + editedSelectedDuration, } } diff --git a/assets/src/models/createDetourMachine.ts b/assets/src/models/createDetourMachine.ts index b52ad35a3..00d537ed9 100644 --- a/assets/src/models/createDetourMachine.ts +++ b/assets/src/models/createDetourMachine.ts @@ -38,6 +38,8 @@ export const createDetourMachine = setup({ selectedReason?: string activatedAt?: Date + + editedSelectedDuration?: string }, input: {} as @@ -87,6 +89,13 @@ export const createDetourMachine = setup({ | { type: "detour.share.activate-modal.cancel" } | { type: "detour.share.activate-modal.back" } | { type: "detour.share.activate-modal.activate" } + | { type: "detour.active.open-change-duration-modal" } + | { + type: "detour.active.change-duration-modal.select-duration" + duration: string + } + | { type: "detour.active.change-duration-modal.done" } + | { type: "detour.active.change-duration-modal.cancel" } | { type: "detour.active.open-deactivate-modal" } | { type: "detour.active.deactivate-modal.deactivate" } | { type: "detour.active.deactivate-modal.cancel" } @@ -671,6 +680,14 @@ export const createDetourMachine = setup({ "detour.active.open-deactivate-modal": { target: "Deactivating", }, + "detour.active.open-change-duration-modal": { + target: "Changing Duration", + actions: assign({ + editedSelectedDuration: ({ + context: { selectedDuration }, + }) => selectedDuration, + }), + }, }, }, Deactivating: { @@ -683,6 +700,27 @@ export const createDetourMachine = setup({ }, }, }, + "Changing Duration": { + on: { + "detour.active.change-duration-modal.select-duration": { + target: "Changing Duration", + actions: assign({ + editedSelectedDuration: ({ event }) => event.duration, + }), + }, + "detour.active.change-duration-modal.done": { + target: "Reviewing", + actions: assign({ + selectedDuration: ({ + context: { editedSelectedDuration }, + }) => editedSelectedDuration, + }), + }, + "detour.active.change-duration-modal.cancel": { + target: "Reviewing", + }, + }, + }, Done: { type: "final" }, }, onDone: { diff --git a/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap b/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap index 1ffc76dc8..f7958eccf 100644 --- a/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap +++ b/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap @@ -155,7 +155,7 @@ exports[`Detours Page: Open a Detour renders detour details in an open drawer on
- Headsign 8 + Headsign A
- Headsign 5 + Headsign A
{ + jest.mocked(fetchDetours).mockReturnValue(neverPromise()) + jest.mocked(fetchDetour).mockReturnValue(neverPromise()) + jest.mocked(putDetourUpdate).mockReturnValue(neverPromise()) + + jest + .mocked(getTestGroups) + .mockReturnValue([ + TestGroups.DetoursPilot, + TestGroups.DetoursList, + TestGroups.ChangeDetourDuration, + ]) +}) + +const DiversionPage = (props: Partial) => { + return ( + null} + {...props} + /> + ) +} + +const threeHoursRadio = byRole("radio", { name: "3 hours" }) +const cancelButton = byRole("button", { name: "Cancel" }) +const doneButton = byRole("button", { name: "Done" }) +const changeDurationButton = byRole("button", { name: "Change duration" }) +const changeDurationHeading = byRole("heading", { + name: "Change detour duration", +}) + +describe("DiversionPage edit duration workflow", () => { + describe("before change duration modal", () => { + test("does not have a change duration button if not in the ChangeDetourDuration group", async () => { + jest + .mocked(getTestGroups) + .mockReturnValue([TestGroups.DetoursList, TestGroups.DetoursPilot]) + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue(Ok(activeDetourFactory.build())) + + render() + + await userEvent.click(await screen.findByText("Headsign A")) + + expect(changeDurationButton.query()).not.toBeInTheDocument() + }) + + test("does not have a change duration button if not an active detour", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue(Ok(detourInProgressFactory.build())) + + render() + + await userEvent.click(await screen.findByText("Headsign A")) + + expect(changeDurationButton.query()).not.toBeInTheDocument() + }) + + test("has a change duration button on the review details screen", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue(Ok(activeDetourFactory.build())) + + render() + + await userEvent.click(await screen.findByText("Headsign A")) + + expect(changeDurationButton.get()).toBeVisible() + }) + + test("does not show change duration modal before clicking the button", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue(Ok(activeDetourFactory.build())) + + render() + + await userEvent.click(await screen.findByText("Headsign A")) + + expect(changeDurationHeading.query()).not.toBeInTheDocument() + }) + + test("clicking change duration button shows the modal", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue(Ok(activeDetourFactory.build())) + + render() + + await userEvent.click(await screen.findByText("Headsign A")) + await userEvent.click(changeDurationButton.get()) + + expect(changeDurationHeading.get()).toBeVisible() + }) + }) + + describe("from the change duration modal", () => { + test("clicking a new duration changes the radio selection but not the previous duration", async () => { + const { state } = activeDetourFactory.build() + + const result = render() + + await userEvent.click(changeDurationButton.get()) + + expect( + result.getByTestId("change-detour-duration-previous-time") + ).toHaveTextContent(/2 hours/) + await userEvent.click(threeHoursRadio.get()) + expect(threeHoursRadio.get()).toBeChecked() + expect( + result.getByTestId("change-detour-duration-previous-time") + ).toHaveTextContent(/2 hours/) + }) + + test("changing duration and clicking cancel does not save the duration", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue( + Ok( + activeDetourFactory.build( + {}, + { transient: { duration: "2 hours" } } + ) + ) + ) + + const result = render() + + await userEvent.click(await screen.findByText("Headsign A")) + await userEvent.click(changeDurationButton.get()) + + await userEvent.click(threeHoursRadio.get()) + await userEvent.click(cancelButton.get()) + + await userEvent.click(changeDurationButton.get()) + expect( + result.getByTestId("change-detour-duration-previous-time") + ).toHaveTextContent(/2 hours/) + }) + + test("changing the duration and clicking done saves the duration", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue( + Ok( + activeDetourFactory.build( + {}, + { transient: { duration: "2 hours" } } + ) + ) + ) + + const result = render() + + await userEvent.click(await screen.findByText("Headsign A")) + await userEvent.click(changeDurationButton.get()) + + await userEvent.click(threeHoursRadio.get()) + await userEvent.click(doneButton.get()) + + await userEvent.click(changeDurationButton.get()) + expect( + result.getByTestId("change-detour-duration-previous-time") + ).toHaveTextContent(/3 hours/) + }) + + test("not changing duration and clicking done doesn't change the duration", async () => { + jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build())) + jest + .mocked(fetchDetour) + .mockResolvedValue( + Ok( + activeDetourFactory.build( + {}, + { transient: { duration: "2 hours" } } + ) + ) + ) + + const result = render() + + await userEvent.click(await screen.findByText("Headsign A")) + await userEvent.click(changeDurationButton.get()) + + await userEvent.click(doneButton.get()) + + await userEvent.click(changeDurationButton.get()) + expect( + result.getByTestId("change-detour-duration-previous-time") + ).toHaveTextContent(/2 hours/) + }) + }) +}) diff --git a/assets/tests/factories/detourListFactory.ts b/assets/tests/factories/detourListFactory.ts index 137c70b2a..75fd95dc0 100644 --- a/assets/tests/factories/detourListFactory.ts +++ b/assets/tests/factories/detourListFactory.ts @@ -13,7 +13,9 @@ export const detourListFactory = Factory.define(() => { active: [ simpleDetourFromActivatedData(activeDetourDataFactory.build()), simpleDetourFromActivatedData( - activeDetourDataFactory.build({ details: { direction: "Outbound" } }) + activeDetourDataFactory.build({ + details: { name: "Headsign A", direction: "Outbound" }, + }) ), ], draft: undefined, diff --git a/assets/tests/factories/detourStateMachineFactory.ts b/assets/tests/factories/detourStateMachineFactory.ts index 788b80995..521e746a9 100644 --- a/assets/tests/factories/detourStateMachineFactory.ts +++ b/assets/tests/factories/detourStateMachineFactory.ts @@ -34,8 +34,15 @@ export const detourInProgressFactory = Factory.define(() => { } }) -export const activeDetourFactory = Factory.define(() => { - // Stub out a detour machine, and start a detour-in-progress +interface DetourWithStateTransientParams { + duration: string +} + +export const activeDetourFactory = Factory.define< + DetourWithState, + DetourWithStateTransientParams +>(({ transientParams }) => { + // Activated detour machine const machine = createActor(createDetourMachine, { input: originalRouteFactory.build(), }).start() @@ -55,12 +62,12 @@ export const activeDetourFactory = Factory.define(() => { machine.send({ type: "detour.share.open-activate-modal" }) machine.send({ type: "detour.share.activate-modal.select-duration", - duration: "", + duration: transientParams.duration ? transientParams.duration : "2 hours", }) machine.send({ type: "detour.share.activate-modal.next" }) machine.send({ type: "detour.share.activate-modal.select-reason", - reason: "", + reason: "Emergency", }) machine.send({ type: "detour.share.activate-modal.next" }) machine.send({ type: "detour.share.activate-modal.activate" })