ref(replays): Move replay details actions into the page-title menu - #123569
ref(replays): Move replay details actions into the page-title menu#123569Jesse-Box wants to merge 9 commits into
Conversation
The page-title crumb invites consumers to inline conditionals in `trailingActions` and drops the falsy entries for them, but it keyed each action by its index after that filtering. An action appearing in an earlier slot therefore shifted every later action onto a new key, remounting it and discarding its state. In practice a dropdown left open would close on its own as soon as a conditional action ahead of it rendered. Key on the slot an action was declared in instead, so filling a slot no longer disturbs its siblings. Refs DE-1419
The details header split its actions across the top bar: a "Configure Replay" dropdown and an ellipsis dropdown sat in the `actions` slot, far from the replay they act on. Fold both into a single actions menu rendered as a page-title trailing action, matching the trace view and transaction summary headers. `ReplayItemDropdown` and `ConfigureReplayCard` become hooks returning menu entries, with the configure doc links nested in a submenu so the merged menu stays short. Their subtitles move to the menu item's own `details` prop, dropping three styled components. Docs-clicked tracking moves onto each link, since the menu-level handler it used to ride on now fires for the sibling actions too. Copying a link to the current timestamp is removed per the design. The `actions` slot is now empty, so `ReplayDetailsHeaderActions` goes with it and the feedback button falls through to the top bar's own fallback, which also tags feedback with its source. The live-refresh chip is restyled as a zero-size primary button. The replay poll mock in the details spec had no timestamps, so mapping it to a replay record threw. It went unnoticed while every test was synchronous; the new ones await user events and surface it. Fixes DE-1419
Downloading the replay record, opening the replay debugger and downloading the first video segment are all visible only to Sentry employees, but sat interleaved with the actions everyone sees. Collect them into a "Sentry Employee Features" section at the end of the menu, so it is obvious at a glance which actions others cannot see. An item with `children` and no `submenu` renders as a labelled group, and the menu draws the divider above it, so no manual separator is needed. The section drops out entirely rather than rendering an empty heading when none of its actions apply. The debug feature badges go with the move: the heading already says who these rows are for, and the video-segment action never carried one. Refs DE-1419
The page title shows a shortened replay ID, which is not what the API, the search syntax or a support thread will accept. Nothing on the page offered the full one, so reading it meant pulling it out of the URL. Add a copy action at the top of the page-title menu that puts the full ID on the clipboard, and disable it until the replay record has loaded. Refs DE-1419
Sharing a replay is the far more common of the two, so it leads. Refs DE-1419
…thheld The details header withholds the replay reader when a replay is only partially readable, so the actions needing its frames disable themselves. `useReplayMenuItems` derived `isMobile` from that same reader, so a mobile replay with processing errors looked like a web one. Two consequences: the video-segment download disappeared rather than rendering disabled, and the Configure Replay submenu linked the JavaScript docs instead of the SDK's own — the second affecting every viewer, not just employees. Read `isMobile` before the reader is withheld and pass it in; a partially-readable replay is still a mobile one. Refs DE-1419
|
bugbot run |
The rationale they carried is preserved in the commit messages for each change and in the pull request description.
… slot Without it the flatMap reads as a roundabout filter, and simplifying it back restores the bug where an open dropdown closed on its own.
| it('keeps an open dropdown open when an action declared before it appears', async () => { | ||
| function Title({showUpdate}: {showUpdate: boolean}) { | ||
| return ( | ||
| <BreadcrumbList.Title | ||
| item={{ | ||
| type: 'page-title', | ||
| label: 'JAVASCRIPT-2X9', | ||
| trailingActions: [ | ||
| showUpdate | ||
| ? {type: 'button', element: <Button size="zero">Update</Button>} | ||
| : null, | ||
| { | ||
| type: 'menu', | ||
| triggerLabel: 'More actions', | ||
| items: [{key: 'delete', label: 'Delete'}], | ||
| }, | ||
| ], | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| const {rerender} = render(<Title showUpdate={false} />); | ||
|
|
||
| await userEvent.click(screen.getByRole('button', {name: 'More actions'})); | ||
| expect( | ||
| await screen.findByRole('menuitemradio', {name: 'Delete'}) | ||
| ).toBeInTheDocument(); | ||
|
|
||
| rerender(<Title showUpdate />); | ||
|
|
||
| expect(screen.getByRole('button', {name: 'Update'})).toBeInTheDocument(); | ||
| expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toBeInTheDocument(); | ||
| }); |
There was a problem hiding this comment.
| it('keeps an open dropdown open when an action declared before it appears', async () => { | |
| function Title({showUpdate}: {showUpdate: boolean}) { | |
| return ( | |
| <BreadcrumbList.Title | |
| item={{ | |
| type: 'page-title', | |
| label: 'JAVASCRIPT-2X9', | |
| trailingActions: [ | |
| showUpdate | |
| ? {type: 'button', element: <Button size="zero">Update</Button>} | |
| : null, | |
| { | |
| type: 'menu', | |
| triggerLabel: 'More actions', | |
| items: [{key: 'delete', label: 'Delete'}], | |
| }, | |
| ], | |
| }} | |
| /> | |
| ); | |
| } | |
| const {rerender} = render(<Title showUpdate={false} />); | |
| await userEvent.click(screen.getByRole('button', {name: 'More actions'})); | |
| expect( | |
| await screen.findByRole('menuitemradio', {name: 'Delete'}) | |
| ).toBeInTheDocument(); | |
| rerender(<Title showUpdate />); | |
| expect(screen.getByRole('button', {name: 'Update'})).toBeInTheDocument(); | |
| expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toBeInTheDocument(); | |
| }); | |
| it('keeps the menu open when the Update button appears to its right', async () => { | |
| function TestTitle({showEarlierAction}: {showEarlierAction: boolean}) { | |
| return ( | |
| <BreadcrumbList.Title | |
| item={{ | |
| type: 'page-title', | |
| label: 'JAVASCRIPT-2X9', | |
| trailingActions: [ | |
| { | |
| type: 'menu', | |
| triggerLabel: 'More actions', | |
| items: [{key: 'menu-item', label: 'Menu item'}], | |
| }, | |
| showEarlierAction | |
| ? { | |
| type: 'button', | |
| element: <Button size="zero">Earlier action</Button>, | |
| } | |
| : null, | |
| ], | |
| }} | |
| /> | |
| ); | |
| } | |
| const {rerender} = render(<TestTitle showEarlierAction={false} />); | |
| await userEvent.click(screen.getByRole('button', {name: 'More actions'})); | |
| expect( | |
| await screen.findByRole('menuitemradio', {name: 'Menu item'}) | |
| ).toBeVisible(); | |
| rerender(<TestTitle showEarlierAction />); | |
| expect(screen.getByRole('button', {name: 'Earlier action'})).toBeVisible(); | |
| // The visible menu item proves the dropdown retained its open state. | |
| expect(screen.getByRole('menuitemradio', {name: 'Menu item'})).toBeVisible(); |
There was a problem hiding this comment.
Held off on this one — I tried it and it stops catching the bug.
Your version declares the menu first, so the appearing button lands in the slot after it. Under the old keying (index assigned after the nulls are dropped) the menu keeps index 0 in both states, never remounts, and the test passes against the buggy implementation — I reverted the fix locally to check, and it went green. The remount only happens when the conditional action is declared before the menu, which is why it is arranged that way.
Took the rest though: renamed to TestTitle, clearer test name, and toBeVisible() over toBeInTheDocument().
| it('groups the employee-only actions into their own section', async () => { | ||
| ConfigStore.set( | ||
| 'user', | ||
| UserFixture({ | ||
| id: '1', | ||
| emails: [{id: '1', email: 'someone@sentry.io', is_verified: true}], | ||
| }) | ||
| ); | ||
|
|
||
| renderDetails(); | ||
|
|
||
| await userEvent.click(screen.getByRole('button', {name: 'Replay Actions'})); | ||
|
|
||
| const section = await screen.findByRole('group', { | ||
| name: 'Sentry Employee Features', | ||
| }); | ||
| expect( | ||
| screen.queryByRole('menuitemradio', {name: 'Sentry Employee Features'}) | ||
| ).not.toBeInTheDocument(); | ||
|
|
||
| expect( | ||
| await screen.findByRole('menuitemradio', {name: 'Download Replay Record'}) | ||
| ).toBeInTheDocument(); | ||
| expect(section).toContainElement( | ||
| screen.getByRole('menuitemradio', {name: 'Download Replay Record'}) | ||
| ); | ||
| expect(section).toContainElement( | ||
| screen.getByRole('menuitemradio', {name: /Sentry Replay Debugger/}) | ||
| ); | ||
|
|
||
| expect(section).not.toContainElement( | ||
| screen.getByRole('menuitemradio', {name: 'Download JSON'}) | ||
| ); | ||
|
|
||
| expect( | ||
| screen.getAllByRole('menuitemradio').map(el => el.textContent?.trim()) | ||
| ).toEqual([ | ||
| 'Copy replay ID to clipboard', | ||
| 'Share', | ||
| 'Download JSON', | ||
| 'Delete', | ||
| 'Configure Replay', | ||
| expect.stringContaining('Download Replay Record'), | ||
| expect.stringContaining('Sentry Replay Debugger'), | ||
| ]); | ||
| }); |
There was a problem hiding this comment.
i think if you apply my suggestions above we can delete this one
There was a problem hiding this comment.
Kept it, but pulled it out into its own test. It pins two things within() cannot: that the employee section sorts last, and that Share precedes Download JSON — both explicit design decisions rather than incidental ordering.
priscilawebdev
left a comment
There was a problem hiding this comment.
left a few suggestions, but lgtm ...great work 🚀👏
Translate the download error messages, which were passed to addErrorMessage as bare strings, and lift the missing-replay guards out of the try blocks that could not throw for them. Tests: spy on the clipboard rather than reassigning it onto navigator, scope the menu assertions with within(), and split the ordering expectation out of the employee-section test. The configuration submenu now asserts its documentation links rather than only that the items are enabled.
I'd rather not touch this right now as I remember this being a heavily discussed UI piece when I was still working on the project. Having said that, I can easily imagine this being an IconButton. |

Introduction
Consolidates a lot of the replay details page actions into a single actions menu on the page title, per DE-1419. Same shape as the trace view (#120794) and transaction summary (#123128) headers.
Screenshot Before
Screenshot After 1
Screenshot After 2
Whats specifically changed in replay details
ReplayItemDropdownandConfigureReplayCardbecome hooks returning menu entries; the configure doc links nest in a submenu, and the employee-only actions group into a labelled section.ReplayDetailsHeaderActions, leaving theactionsempty and the feedback button falls through to the top bar's own fallback. The live-refresh chip is restyled as a zero-size primary button.Whats changed in
<BreadcrumbList/>Bug fixed in
BreadcrumbList, affecting other consumers: the page-title crumb keyed eachtrailingActionsentry by its index after dropping the falsy ones, so an action appearing in an earlier slot remounted every later action and discarded its state — an open dropdown closed on its own as soon as the live-refresh chip rendered beside it. Now keyed on the slot an action was declared in. The trace and transaction-summary headers each pass a single action rather than an array, so neither changes behaviour today.Fixes DE-1419