Skip to content

Commit

Permalink
feat: Add dropdown when right-clicking on a vehicle marker (#2373)
Browse files Browse the repository at this point in the history
* feat: Add dropdown when right-clicking on a vehicle marker

* refactor: Extract <DropdownMenu /> to its own file

* feat(storybook): Add Storybook story for <DropdownMenu />

* feat(test-groups): Gate "Start Detour" dropdown behind a test group

* feat: Only allow the dropdown on desktop and tablet

* refactor: Make the popup a children property of the <VehicleMarker />

* refactor: Make shouldShowPopup state that's passed into a <VehicleMarker />

* feat: Close popup when its menu item is clicked

* test: Move the dropdown tests so that they test the right use case

* test: Assert using getByRole and queryByRole rather than looking for CSS classes
  • Loading branch information
joshlarson authored Feb 6, 2024
1 parent f160f3f commit c071338
Show file tree
Hide file tree
Showing 10 changed files with 307 additions and 15 deletions.
8 changes: 8 additions & 0 deletions assets/css/_dropdown.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.c-dropdown-popup-wrapper {
visibility: hidden;
}

.c-dropdown-popup-menu {
position: static;
visibility: visible;
}
1 change: 1 addition & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ $z-properties-panel-context: (
@import "disconnected_modal";
@import "diversion_page";
@import "drawer_tab";
@import "dropdown";
@import "filter_accordion";
@import "garage_filter";
@import "icon_alert_circle";
Expand Down
2 changes: 1 addition & 1 deletion assets/css/bootstrap.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
// @import "../node_modules/bootstrap/scss/carousel";
// @import "../node_modules/bootstrap/scss/close";
// @import "../node_modules/bootstrap/scss/containers";
// @import "../node_modules/bootstrap/scss/dropdown";
@import "../node_modules/bootstrap/scss/dropdown";
@import "../node_modules/bootstrap/scss/forms";
// @import "../node_modules/bootstrap/scss/grid";
// @import "../node_modules/bootstrap/scss/images";
Expand Down
17 changes: 17 additions & 0 deletions assets/src/components/map/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { PropsWithChildren } from "react"
import { Dropdown } from "react-bootstrap"

interface DropdownMenuProps extends PropsWithChildren {}

export const DropdownMenu = ({ children }: DropdownMenuProps) => {
return (
<Dropdown.Menu
className="c-dropdown-popup-menu border-box inherit-box"
show
>
{children}
</Dropdown.Menu>
)
}

export const DropdownItem = Dropdown.Item
65 changes: 56 additions & 9 deletions assets/src/components/mapMarkers.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import Leaflet, { LatLngExpression } from "leaflet"
import "leaflet-defaulticon-compatibility" // see https://github.com/Leaflet/Leaflet/issues/4968#issuecomment-483402699
import "leaflet.fullscreen"
import React, { useContext } from "react"
import React, {
PropsWithChildren,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { Marker, Polyline, Popup, Tooltip } from "react-leaflet"

import { StateDispatchContext } from "../contexts/stateDispatchContext"
Expand Down Expand Up @@ -96,19 +102,55 @@ const makeLabelIcon = (
})
}

interface VehicleMarkerProps extends PropsWithChildren {
vehicle: Vehicle
isPrimary: boolean
isSelected?: boolean
onSelect?: (vehicle: Vehicle) => void
shouldShowPopup?: boolean
onShouldShowPopupChange?: (newValue: boolean) => void
}

export const VehicleMarker = ({
children,
vehicle,
isPrimary,
onSelect,
isSelected = false,
}: {
vehicle: Vehicle
isPrimary: boolean
isSelected?: boolean
onSelect?: (vehicle: Vehicle) => void
}) => {
shouldShowPopup = false,
onShouldShowPopupChange = () => {},
}: VehicleMarkerProps) => {
const [{ userSettings }] = useContext(StateDispatchContext)
const eventHandlers = onSelect ? { click: () => onSelect(vehicle) } : {}
const markerRef = useRef<Leaflet.Marker<any>>(null)

const [isPopupVisible, setIsPopupVisible] = useState<boolean>(false)

useEffect(() => {
if (shouldShowPopup && !isPopupVisible) {
markerRef.current?.openPopup()
}

if (!shouldShowPopup && isPopupVisible) {
markerRef.current?.closePopup()
}
}, [shouldShowPopup, isPopupVisible])

const eventHandlers = {
click: () => {
onSelect && onSelect(vehicle)
onShouldShowPopupChange(false)
},
contextmenu: () => {
onShouldShowPopupChange(true)
},
popupopen: () => {
setIsPopupVisible(true)
},
popupclose: () => {
setIsPopupVisible(false)
onShouldShowPopupChange(false)
},
}
const position: LatLngExpression = [vehicle.latitude, vehicle.longitude]
const vehicleIcon: Leaflet.DivIcon = makeVehicleIcon(
vehicle,
Expand All @@ -128,14 +170,19 @@ export const VehicleMarker = ({
// > [...] if you want to put the marker on top of all others,
// > [specify] a high value like 1000 [...]
const zIndexOffset = isSelected ? 1000 : 0

return (
<>
<Marker
position={position}
icon={vehicleIcon}
eventHandlers={eventHandlers}
zIndexOffset={zIndexOffset}
/>
ref={markerRef}
>
{children}
</Marker>

<Marker
position={position}
icon={labelIcon}
Expand Down
43 changes: 40 additions & 3 deletions assets/src/components/mapPage/mapDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import Leaflet from "leaflet"
import Leaflet, { PointTuple } from "leaflet"
import React, {
useCallback,
useContext,
useEffect,
useState,
useMemo,
} from "react"
import { Pane, useMap } from "react-leaflet"
import { Pane, Popup, useMap } from "react-leaflet"
import { SocketContext } from "../../contexts/socketContext"
import useMostRecentVehicleById from "../../hooks/useMostRecentVehicleById"
import usePatternsByIdForRoute from "../../hooks/usePatternsByIdForRoute"
Expand Down Expand Up @@ -60,6 +60,9 @@ import { LocationType, RouteType } from "../../models/stopData"
import usePullbackVehicles from "../../hooks/usePullbackVehicles"
import { fullStoryEvent } from "../../helpers/fullStory"
import { RecenterControl } from "../map/controls/recenterControl"
import { DropdownItem, DropdownMenu } from "../map/dropdown"
import useScreenSize from "../../hooks/useScreenSize"
import inTestGroup, { TestGroups } from "../../userInTestGroup"

const SecondaryRouteVehicles = ({
selectedVehicleRoute,
Expand Down Expand Up @@ -280,6 +283,21 @@ const SelectedVehicleDataLayers = ({
useCurrentZoom,
paddingTopLeft || [0, 0]
)
const screenSize = useScreenSize()

// This offset is here because, due to a limitation of Leaflet
// popups, we weren't able to render the popup at the bottom-right
// corner of the marker, where it's supposed to go. This effectively
// renders it centered and above the marker, and then uses the
// offset to reposition it to the bottom-right corner.
const dropdownOffset: PointTuple = [140, 97]

const dropdownEnabled =
inTestGroup(TestGroups.DetoursPilot) &&
["desktop", "tablet"].includes(screenSize)

const [shouldShowPopup, setShouldShowPopup] = useState<boolean>(false)

return (
<>
{selectedVehicleOrGhost && (
Expand All @@ -292,7 +310,26 @@ const SelectedVehicleDataLayers = ({
isPrimary={true}
isSelected={true}
onSelect={selectVehicle}
/>
shouldShowPopup={shouldShowPopup}
onShouldShowPopupChange={setShouldShowPopup}
>
{dropdownEnabled && (
<Popup
className="c-dropdown-popup-wrapper"
offset={dropdownOffset}
>
<DropdownMenu>
<DropdownItem
onClick={() => {
setShouldShowPopup(false)
}}
>
Start a detour on route {selectedVehicleOrGhost.routeId}
</DropdownItem>
</DropdownMenu>
</Popup>
)}
</VehicleMarker>

{!selectedVehicleOrGhost.isShuttle && (
<SecondaryRouteVehicles
Expand Down
1 change: 1 addition & 0 deletions assets/src/userInTestGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import getTestGroups from "./userTestGroups"

export enum TestGroups {
DemoMode = "demo-mode",
DetoursPilot = "detours-pilot",
DummyDetourPage = "dummy-detour-page",
LateView = "late-view",
}
Expand Down
43 changes: 43 additions & 0 deletions assets/stories/skate-components/map/dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react"

import React from "react"
import {
DropdownItem,
DropdownMenu,
} from "../../../src/components/map/dropdown"

const meta = {
component: DropdownMenu,
args: {
children: ["Start a detour on route 66"],
},
parameters: {
layout: "centered",
},
decorators: [
(StoryFn, ctx) => {
const children = ctx.args["children"] as Array<string>

ctx.args["children"] = children.map((child) => (
<DropdownItem key={child}>{child}</DropdownItem>
))

return <StoryFn />
},
],
} satisfies Meta<typeof DropdownMenu>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const WithMoreEntries: Story = {
args: {
children: [
"Start a detour on route 66",
"Hold this bus for 10 minutes",
"View old detours for this route",
],
},
}
6 changes: 5 additions & 1 deletion assets/tests/components/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { runIdToLabel } from "../../src/helpers/vehicleLabel"
import getTestGroups from "../../src/userTestGroups"
import { LocationType } from "../../src/models/stopData"
import { setHtmlDefaultWidthHeight } from "../testHelpers/leafletMapWidth"
import { mockTileUrls } from "../testHelpers/mockHelpers"
import { mockScreenSize, mockTileUrls } from "../testHelpers/mockHelpers"
import { streetViewModeSwitch } from "../testHelpers/selectors/components/mapPage/map"
import { streetViewUrl } from "../../src/util/streetViewUrl"
import shapeFactory from "../factories/shape"
Expand Down Expand Up @@ -78,6 +78,10 @@ beforeEach(() => {
;(getTestGroups as jest.Mock).mockReturnValue([])
})

beforeEach(() => {
mockScreenSize("desktop")
})

afterAll(() => {
global.scrollTo = originalScrollTo
})
Expand Down
Loading

0 comments on commit c071338

Please sign in to comment.