RF-1224: Use deployment dates instead of update dates in gear popup#1550
RF-1224: Use deployment dates instead of update dates in gear popup#1550jeslefcourt wants to merge 6 commits into
Conversation
Gear popup header and device list now show last_deployed timestamps. The header falls back to gear.last_updated when no device has a deployment date. Devices are sorted ascending by last_deployed, with undated devices last. Popup width increased 25% and name is now clickable (jump to location). Adds test coverage for device sort order and popup rendering branches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Updates the Gear popup and sidebar interactions to use deployment dates (instead of update dates) and improve navigation to gear locations.
Changes:
- Gear popup header now uses the most recent
device.last_deployed(fallback togear.last_updated) and device rows showlast_deployed. - Devices in the popup are sorted ascending by
last_deployed(undated devices last; ties preserve order). - Gear popup styling updated to widen the Mapbox popup content, and gear names in the sidebar are now clickable to jump to location.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/SideBar/GearTab/styles.module.scss | Adds styling for a “clickable” gear display id in the sidebar list. |
| src/SideBar/GearTab/index.js | Makes the gear name clickable/keyboard-focusable to jump to the gear’s location. |
| src/PopupLayer/index.js | Applies a GearPopup-specific popup class for width styling via popup template attrs. |
| src/GearPopup/styles.module.scss | Adds a .popup wrapper to increase Mapbox popup content width for gear popups. |
| src/GearPopup/index.js | Switches popup header/device dates to last_deployed logic and sorts devices by deployment date. |
| src/GearPopup/index.test.js | Adds unit tests for GearPopup rendering and device sort order. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…oyed across devices Agent-Logs-Url: https://github.com/PADAS/das-web-react/sessions/5f2a8b61-505a-4b8d-bebe-cedff1eba9cd Co-authored-by: jeslefcourt <32883811+jeslefcourt@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@claude review |
There was a problem hiding this comment.
LGTM — small frontend change with good test coverage.
Extended reasoning...
Overview
This PR updates the gear popup to surface the most recent device last_deployed date (with fallback to gear.last_updated), sorts devices ascending by deployment date, widens the popup, and makes the gear name in the sidebar list clickable to jump to location. Touches 7 files, all frontend (React + SCSS), with a new 141-line test file covering the popup behavior and sort ordering edge cases.
Security risks
None. No auth, crypto, network calls, user input parsing, or permission logic. Date strings are compared lexicographically (safe for ISO 8601), and the new offset option to useJumpToLocation is conditionally spread only when truthy.
Level of scrutiny
Low. This is presentation-layer logic with clear intent, deterministic behavior, and direct test coverage. The most-recent-date reduce, the sort comparator, and the conditional offset spread are all small and easy to verify by inspection. The test file specifically covers the most-recent-date selection (added in 00bfe94 in response to Copilot feedback), the fallback path, undated-devices-last ordering, and stable sort for ties.
Other factors
- Prior Copilot review surfaced 4 items; 3 were addressed (focus-visible styling now present in SCSS, missing test added, PR description corrected).
- The remaining nit (Space key on the role=button span doesn't
preventDefault, so Space both activates and scrolls the page) is flagged as an inline comment by the bug-hunting system. It's a real a11y nit but minor and isolated — does not block approval. - No CODEOWNER-sensitive areas touched. The
useJumpToLocationhook change is backward-compatible (new optional second-arg property).
| className={`${styles.displayId}${coordinates ? ` ${styles.displayIdClickable}` : ''}`} | ||
| onClick={coordinates ? onJumpClick : undefined} | ||
| role={coordinates ? 'button' : undefined} | ||
| tabIndex={coordinates ? 0 : undefined} | ||
| onKeyDown={coordinates ? (e) => { if (e.key === 'Enter' || e.key === ' ') onJumpClick(); } : undefined} |
There was a problem hiding this comment.
🟡 Pressing Space on the focused gear title (a span with role=button and tabIndex=0) both jumps to location AND scrolls the page because the onKeyDown handler at src/SideBar/GearTab/index.js:65-69 handles Space without calling e.preventDefault(). Fix by either rendering a real <button type="button"> (preferred) or calling e.preventDefault() when e.key === ' '. Copilot raised this as inline thread 3134201681; the thread was marked resolved but no code change was applied.
Extended reasoning...
The bug
The clickable gear title is rendered as a <span> (not a native <button>) with role="button" and tabIndex={0}, plus a custom onKeyDown handler:
onKeyDown={coordinates ? (e) => { if (e.key === 'Enter' || e.key === ' ') onJumpClick(); } : undefined}For real <button> elements, browsers automatically suppress the default Space-scroll behavior. For a focused, tabbable <span>, they do not — Space triggers the page's default scroll-down behavior unless the handler explicitly calls e.preventDefault(). This handler does not.
Why existing code does not prevent it
There is no preventDefault() anywhere on this path, and no parent handler intercepts Space on this element. Enter does not have a default scroll behavior on focused elements, so Enter still works as intended — the bug is specific to Space.
Impact
A keyboard user who tabs to the gear name and presses Space will see the map jump to the gear's location AND the page scroll down by one viewport. This is confusing because the user pressed an activation key on what looks like a button, but got an unrelated scroll as well. It also breaks expected button semantics (Space should activate-on-keyup with no default side effects), which is a minor accessibility regression on a brand-new clickable affordance.
Step-by-step proof
- Open the sidebar Gear tab so the gear list is rendered.
- Tab through the page until focus lands on the gear-name span (it has
tabIndex=0whencoordinatesis truthy). - Press the Space key.
- The
onKeyDownhandler runs and callsonJumpClick()→ the map eases to the gear coordinates and the popup is shown. - Because
e.preventDefault()was never called, the browser also performs its default Space-on-focused-element behavior: scrolling the document by ~one viewport. - Net result: both the jump-and-popup AND a full-page scroll happen from a single Space press.
For comparison, pressing Enter at step 3 only triggers the jump — Enter has no default scroll behavior on a focused span.
Fix
Either of the following:
- Preferred: render a real
<button type="button">(matches Copilot's suggestion in inline thread 3134201681). This eliminates the need forrole/tabIndex/onKeyDownentirely and gets correct keyboard semantics for free. - Minimal: keep the span but call
e.preventDefault()whene.key === ' '(and ideally activate onkeyupto match native button behavior):
onKeyDown={coordinates ? (e) => {
if (e.key === 'Enter') onJumpClick();
else if (e.key === ' ') e.preventDefault();
} : undefined}
onKeyUp={coordinates ? (e) => { if (e.key === ' ') onJumpClick(); } : undefined}History note
Copilot raised this exact issue in inline review 3134201681 with a code suggestion to switch to a native <button>. The thread is marked resolved in the PR timeline, but the modified-files content shows the handler is unchanged — no preventDefault was added and the element is still a <span>, so the fix was never actually applied.
🔬 also observed by 3134201681
luixlive
left a comment
There was a problem hiding this comment.
Almost done, some small suggestions on improvements for code readability and HTML semantics 👍
| : primaryLabel; | ||
| const { coordinates } = data; | ||
|
|
||
| const mostRecentDate = (gear.devices || []).reduce((best, device) => { |
There was a problem hiding this comment.
We may want to memoize this so we don't run a loop on every render:
const lastDeployedDeviceDate = useMemo(() => (gear.devices || []).reduce((best, device) => {
if (!device.last_deployed) {
return best;
}
return !best || device.last_deployed > best ? device.last_deployed : best;
}, null), [gear.devices]);
const mostRecentUpdateDate = lastDeployedDeviceDate || gear.last_updated;Just consider that in that case we need to put the useMemo hook over the line if (!gear) return null;.
| <dd>{gear.type}</dd> | ||
|
|
||
| {(gear.devices || []).map((device) => <React.Fragment key={device.device_id}> | ||
| {[...(gear.devices || [])].sort((a, b) => { |
There was a problem hiding this comment.
A little bit harsh to read all these conditionals in the middle of the JSX. We can create a helper at the top of the file:
const sortByLastDeployed = (a, b) => {
if (!a.last_deployed && !b.last_deployed) return 0;
if (!a.last_deployed) return 1;
if (!b.last_deployed) return -1;
if (a.last_deployed === b.last_deployed) return 0;
return a.last_deployed > b.last_deployed ? 1 : -1;
};And here just call it:
{(gear.devices ?? []).sort(sortByLastDeployed).map((device) => <React.Fragment key={device.device_id}>|
|
||
| .popup { | ||
| [class*=mapboxgl-popup-content] { | ||
| width: 16.25rem; |
There was a problem hiding this comment.
Not an issue here, just wanted to know if there's a good reason to override the default popup width. Will this new width look good in different device sizes? Mobile ones?
| map.fitBounds(mapBoundaries, { linear: true, speed: 200, padding }); | ||
| } else { | ||
| map.easeTo({ center: isArrayCoords ? coords[0] : coords, zoom, padding, speed: 200 }); | ||
| map.easeTo({ center: isArrayCoords ? coords[0] : coords, zoom, padding, speed: 200, ...(offset ? { offset } : {}) }); |
There was a problem hiding this comment.
This way of receiving the third (options) parameter and spreading it feels a bit weird. What do you think of something like this:
return (coords, zoom = 15, options = {}) => {
const isArrayCoords = Array.isArray(coords[0]);
const padding = calcPadding(currentTab, isArrayCoords, itemId, isMediumLayoutOrLarger);
if (isArrayCoords && coords.length > 1) {
const mapBoundaries = coords.reduce(buildLocationJumpBounds, new LngLatBounds());
map.fitBounds(mapBoundaries, { linear: true, speed: 200, padding, ...options });
} else {
map.easeTo({ center: isArrayCoords ? coords[0] : coords, zoom, padding, speed: 200, ...options });
}
};The options parameter is still optional and you can pass the offset there, which would be spreaded to both fitBounds and easeTo.
| return <> | ||
| <p className={mapLayersStyles.itemTitle} data-testid="gear-item-name"> | ||
| <span className={styles.displayId}>{rowTitle}</span> | ||
| <span |
There was a problem hiding this comment.
I see agents are causing noise around this change, and for a good reason. This is not the most semantic HTML nor the accessible way of hearing a click. It would be more appropriate to render a button instead of a span, and add stiles to make it look just like the span. Something like:
{coordinates ? (
<button
className={`${styles.displayId} ${styles.displayIdClickable}`}
onClick={onJumpClick}
type="button"
>
{rowTitle}
</button>
) : (
<span className={styles.displayId}>
{rowTitle}
</span>
)}Notice the
is gone since intractable elements shouldn't live inside a
.
| word-break: break-word; | ||
| } | ||
|
|
||
| .displayIdClickable { |
There was a problem hiding this comment.
These styles would be updated if you switch to a button as suggested.
Summary
last_deployedacross all devices, falling back togear.last_updatedlast_deployed(undated devices last, stable sort for ties)16.25rem)Fixes: RF-1224
Test plan
last_deployedappear after dated devices in the popup listnpx jest src/GearPopup/index.test.js— all 11 tests pass🤖 Generated with Claude Code