Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Open the detour modal from a notification #2825

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 117 additions & 43 deletions assets/src/components/notificationCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactNode } from "react"
import React, { ReactNode, useCallback, useState } from "react"
import { useRoute, useRoutes } from "../contexts/routesContext"
import {
BlockWaiverNotification,
Expand All @@ -13,6 +13,13 @@ import { CardBody, CardProperties, CardReadable } from "./card"
import { fullStoryEvent } from "../helpers/fullStory"
import { RoutePill } from "./routePill"
import inTestGroup, { TestGroups } from "../userInTestGroup"
import { useApiCall } from "../hooks/useApiCall"
import { fetchDetour } from "../api"
import { createDetourMachine } from "../models/createDetourMachine"
import { isValidSnapshot } from "../util/isValidSnapshot"
import { isErr } from "../util/result"
import { DetourModal } from "./detours/detourModal"
import { DetourId } from "../models/detoursList"

export const NotificationCard = ({
notification,
Expand All @@ -27,6 +34,13 @@ export const NotificationCard = ({
hideLatestNotification?: () => void
noFocusOrHover?: boolean
}) => {
const [showDetourModal, setShowDetourModal] = useState(false)

const detourId =
notification.content.$type === NotificationType.Detour
? notification.content.detourId
: undefined

const routes = useRoutes(
isBlockWaiverNotification(notification) ? notification.content.routeIds : []
)
Expand All @@ -43,52 +57,112 @@ export const NotificationCard = ({
return null
}

const onClose = () => {
setShowDetourModal(false)
}
const isUnread = notification.state === "unread"
return (
<CardReadable
currentTime={currentTime}
title={<>{title(notification)}</>}
style="kiwi"
isActive={isUnread}
openCallback={() => {
openVPPForCurrentVehicle(notification)

if (hideLatestNotification) {
hideLatestNotification()
}

if (notification.content.$type === NotificationType.BridgeMovement) {
fullStoryEvent("User clicked Chelsea Bridge Notification", {})
}
}}
closeCallback={hideLatestNotification}
time={notification.createdAt}
noFocusOrHover={noFocusOrHover}
>
<CardBody>{description(notification, routes, routeAtCreation)}</CardBody>
{isBlockWaiverNotification(notification) && (
<CardProperties
properties={[
{
label: "Run",
value:
notification.content.runIds.length > 0
? notification.content.runIds.join(", ")
: null,
},
{
label: "Operator",
value:
notification.content.operatorName !== null &&
notification.content.operatorId !== null
? `${notification.content.operatorName} #${notification.content.operatorId}`
: null,
sensitive: true,
},
]}
<>
<CardReadable
currentTime={currentTime}
title={<>{title(notification)}</>}
style="kiwi"
isActive={isUnread}
openCallback={() => {
if (notification.content.$type === NotificationType.Detour) {
setShowDetourModal(true)
} else {
openVPPForCurrentVehicle(notification)

if (hideLatestNotification) {
hideLatestNotification()
}

if (
notification.content.$type === NotificationType.BridgeMovement
) {
fullStoryEvent("User clicked Chelsea Bridge Notification", {})
}
}
}}
closeCallback={hideLatestNotification}
time={notification.createdAt}
noFocusOrHover={noFocusOrHover}
>
<CardBody>
{description(notification, routes, routeAtCreation)}
</CardBody>
{isBlockWaiverNotification(notification) && (
<CardProperties
properties={[
{
label: "Run",
value:
notification.content.runIds.length > 0
? notification.content.runIds.join(", ")
: null,
},
{
label: "Operator",
value:
notification.content.operatorName !== null &&
notification.content.operatorId !== null
? `${notification.content.operatorName} #${notification.content.operatorId}`
: null,
sensitive: true,
},
]}
/>
)}
</CardReadable>
{detourId && (
<DetourNotificationModal
show={showDetourModal}
detourId={detourId}
onClose={onClose}
/>
)}
</CardReadable>
</>
)
}

const DetourNotificationModal = ({
detourId,
show,
onClose,
}: {
detourId: DetourId
show: boolean
onClose: () => void
}) => {
const { result: stateOfDetourModal } = useApiCall({
apiCall: useCallback(async () => {
if (detourId === undefined) {
return undefined
}
const detourResponse = await fetchDetour(detourId)
if (isErr(detourResponse)) {
return undefined
}
const snapshot = isValidSnapshot(
createDetourMachine,
detourResponse.ok.state
)
if (isErr(snapshot)) {
return undefined
}
return snapshot.ok
}, [detourId]),
})

return (
<DetourModal
onClose={onClose}
show={show}
originalRoute={{}}
key={detourId ?? ""}
{...(stateOfDetourModal ? { snapshot: stateOfDetourModal } : {})}
/>
)
}

Expand Down
98 changes: 98 additions & 0 deletions assets/tests/components/notificationCard.openDetour.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { jest, describe, test, expect } from "@jest/globals"
import "@testing-library/jest-dom/jest-globals"
import React from "react"
import { render, screen } from "@testing-library/react"
import { NotificationCard } from "../../src/components/notificationCard"
import { detourActivatedNotificationFactory } from "../factories/notification"
import routeFactory from "../factories/route"
import userEvent from "@testing-library/user-event"
import { RoutesProvider } from "../../src/contexts/routesContext"
import { fetchDetour, fetchDetours } from "../../src/api"
import { Ok } from "../../src/util/result"
import { detourListFactory } from "../factories/detourListFactory"
import { createActor } from "xstate"
import { createDetourMachine } from "../../src/models/createDetourMachine"
import { originalRouteFactory } from "../factories/originalRouteFactory"
import { shapePointFactory } from "../factories/shapePointFactory"
import getTestGroups from "../../src/userTestGroups"
import { TestGroups } from "../../src/userInTestGroup"

jest.mock("../../src/api")
jest.mock("../../src/helpers/fullStory")
jest.mock("../../src/userTestGroups")

const routes = [
routeFactory.build({
id: "route1",
name: "r1",
}),
routeFactory.build({
id: "route2",
name: "r2",
}),
routeFactory.build({
id: "route3",
name: "r3",
}),
]

describe("NotificationCard", () => {
test("renders detour details modal to match mocked fetchDetour", async () => {
jest
.mocked(getTestGroups)
.mockReturnValue([TestGroups.DetoursPilot, TestGroups.DetoursList])

jest.mocked(fetchDetours).mockResolvedValue(Ok(detourListFactory.build()))

// Stub out a detour machine, and start a detour-in-progress
const machine = createActor(createDetourMachine, {
input: originalRouteFactory.build(),
}).start()
machine.send({
type: "detour.edit.place-waypoint-on-route",
location: shapePointFactory.build(),
})
machine.send({
type: "detour.edit.place-waypoint",
location: shapePointFactory.build(),
})
machine.send({
type: "detour.edit.place-waypoint-on-route",
location: shapePointFactory.build(),
})
machine.send({ type: "detour.edit.done" })

const snapshot = machine.getPersistedSnapshot()
machine.stop()

// Return the state of the machine as the fetchDetour mocked value,
// even if it doesn't match the detour clicked
jest.mocked(fetchDetour).mockResolvedValue(
Ok({
updatedAt: 1726147775,
author: "fake@email.com",
state: snapshot,
})
)

const n = detourActivatedNotificationFactory.build()

render(
<RoutesProvider routes={routes}>
<NotificationCard
notification={n}
currentTime={new Date()}
openVPPForCurrentVehicle={() => {}}
/>
</RoutesProvider>
)

// await userEvent.click(screen.getByText(/run1/))
await userEvent.click(screen.getByRole("button", { name: /Detour/ }))
// Render modal based on mocked value, which is a detour-in-progress

expect(
screen.getByRole("heading", { name: "Share Detour Details" })
).toBeVisible()
})
})
21 changes: 21 additions & 0 deletions assets/tests/factories/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BlockWaiverReason,
BridgeLoweredNotification,
BridgeRaisedNotification,
DetourNotification,
Notification,
NotificationType,
} from "../../src/realtime"
Expand Down Expand Up @@ -84,3 +85,23 @@ export const bridgeLoweredNotificationFactory = Factory.define<
state: "unread",
content: bridgeLoweredNotificationContentFactory.build(),
}))

const detourActivatedNotificationContentFactory =
Factory.define<DetourNotification>(() => ({
$type: NotificationType.Detour,
status: "activated",
detourId: 79,
headsign: "79 - Wooded Mountains",
route: "79",
direction: "Inbound",
origin: "Maple Knoll",
Comment on lines +93 to +97
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is it possible to not hardcode these values?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit of a struggle when tests assume hardcoded values from factories and then in the future we decide to make them generated, so I'd prefer to start with generated values.

I'd be open to pulling in faker @mbta/skate-developers

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree - I did this just to get something working, but I'd rather get non-hardcoded values in before merging.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was done in #2819

}))

export const detourActivatedNotificationFactory = Factory.define<
Notification<DetourNotification>
>(({ sequence }) => ({
id: sequence.toString(),
createdAt: new Date(),
state: "unread",
content: detourActivatedNotificationContentFactory.build(),
}))
Loading