-
Notifications
You must be signed in to change notification settings - Fork 406
fix: copy current session share URL #2324
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
Changes from all commits
374f013
dce5f0f
1435192
07c3edc
d0417bf
be021e5
68d9d8c
9cad879
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@hyperdx/app': patch | ||
| --- | ||
|
|
||
| fix(app): copy the latest session URL from the Share Session button |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import { useCallback, useMemo, useState } from 'react'; | ||
| import CopyToClipboard from 'react-copy-to-clipboard'; | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||
| import { useHotkeys } from 'react-hotkeys-hook'; | ||
| import { | ||
| DateRange, | ||
|
|
@@ -17,6 +16,10 @@ import { | |
| getInitialDrawerWidthPercent, | ||
| } from '@/components/DrawerUtils'; | ||
| import useResizable from '@/hooks/useResizable'; | ||
| import { | ||
| CLIPBOARD_ERROR_MESSAGE, | ||
| copyTextToClipboard, | ||
| } from '@/utils/clipboard'; | ||
|
|
||
| import { Session } from './sessions'; | ||
| import SessionSubpanel from './SessionSubpanel'; | ||
|
|
@@ -49,6 +52,18 @@ export default function SessionSidePanel({ | |
| }) { | ||
| // Keep track of sub-drawers so we can disable closing this root drawer | ||
| const [subDrawerOpen, setSubDrawerOpen] = useState(false); | ||
| const [isSharingSession, setIsSharingSession] = useState(false); | ||
| const isSharingSessionRef = useRef(false); | ||
| const isMountedRef = useRef(true); | ||
|
|
||
| useEffect(() => { | ||
| // React 18 StrictMode runs cleanup before re-mounting this effect. | ||
| isMountedRef.current = true; | ||
|
|
||
| return () => { | ||
| isMountedRef.current = false; | ||
| }; | ||
| }, []); | ||
|
|
||
| const { size, setSize, startResize } = useResizable( | ||
| getInitialDrawerWidthPercent(), | ||
|
|
@@ -58,6 +73,33 @@ export default function SessionSidePanel({ | |
| setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100); | ||
| }, [isFullWidth, setSize]); | ||
|
|
||
| const handleShareSession = useCallback(async () => { | ||
| if (isSharingSessionRef.current) { | ||
| return; | ||
| } | ||
|
|
||
| isSharingSessionRef.current = true; | ||
| setIsSharingSession(true); | ||
| let copied = false; | ||
| try { | ||
| copied = await copyTextToClipboard(window.location.href); | ||
| } catch { | ||
| copied = false; | ||
|
Comment on lines
+84
to
+87
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } finally { | ||
| isSharingSessionRef.current = false; | ||
| if (isMountedRef.current) { | ||
| setIsSharingSession(false); | ||
| } | ||
| } | ||
| if (!isMountedRef.current) { | ||
| return; | ||
| } | ||
| notifications.show({ | ||
| color: copied ? 'green' : 'red', | ||
| message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, | ||
| }); | ||
| }, []); | ||
|
Comment on lines
+76
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be simplified to: const handleShareSession = useCallback(async () => {
if (isSharingSessionRef.current) return;
isSharingSessionRef.current = true;
setIsSharingSession(true);
const copied = await copyTextToClipboard(window.location.href);
isSharingSessionRef.current = false;
setIsSharingSession(false);
notifications.show({
color: copied ? 'green' : 'red',
message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE,
});
}, []); |
||
|
|
||
| useHotkeys( | ||
| ['esc'], | ||
| () => { | ||
|
|
@@ -125,24 +167,16 @@ export default function SessionSidePanel({ | |
| isFullWidth={isFullWidth} | ||
| onToggle={toggleFullWidth} | ||
| /> | ||
| <CopyToClipboard | ||
| text={window.location.href} | ||
| onCopy={() => { | ||
| notifications.show({ | ||
| color: 'green', | ||
| message: 'Copied link to clipboard', | ||
| }); | ||
| }} | ||
| <Button | ||
| variant="secondary" | ||
| size="sm" | ||
| leftSection={<IconLink size={14} />} | ||
| style={{ fontSize: '12px' }} | ||
| loading={isSharingSession} | ||
| onClick={handleShareSession} | ||
| > | ||
| <Button | ||
| variant="secondary" | ||
| size="sm" | ||
| leftSection={<IconLink size={14} />} | ||
| style={{ fontSize: '12px' }} | ||
| > | ||
| Share Session | ||
| </Button> | ||
| </CopyToClipboard> | ||
| Share Session | ||
| </Button> | ||
| <ActionIcon variant="secondary" size="md" onClick={onClose}> | ||
| <IconX size={14} /> | ||
| </ActionIcon> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| import { StrictMode } from 'react'; | ||
| import { | ||
| SourceKind, | ||
| TSessionSource, | ||
| TTraceSource, | ||
| } from '@hyperdx/common-utils/dist/types'; | ||
| import { notifications } from '@mantine/notifications'; | ||
| import { fireEvent, screen, waitFor } from '@testing-library/react'; | ||
|
|
||
| import { Session } from '../sessions'; | ||
| import SessionSidePanel from '../SessionSidePanel'; | ||
| import { | ||
| CLIPBOARD_ERROR_MESSAGE, | ||
| copyTextToClipboard, | ||
| } from '../utils/clipboard'; | ||
|
|
||
| jest.mock( | ||
| '../SessionSubpanel', | ||
| () => | ||
| function MockSessionSubpanel() { | ||
| return <div data-testid="session-subpanel" />; | ||
| }, | ||
| ); | ||
|
|
||
| jest.mock('@/hooks/useResizable', () => ({ | ||
| __esModule: true, | ||
| default: () => ({ | ||
| size: 50, | ||
| setSize: jest.fn(), | ||
| startResize: jest.fn(), | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock('../utils/clipboard', () => ({ | ||
| ...jest.requireActual('../utils/clipboard'), | ||
| copyTextToClipboard: jest.fn(), | ||
| })); | ||
|
|
||
| const copyTextToClipboardMock = jest.mocked(copyTextToClipboard); | ||
| const notificationsShowSpy = jest | ||
| .spyOn(notifications, 'show') | ||
| .mockImplementation(jest.fn()); | ||
|
|
||
| const traceSource = { | ||
| id: 'trace-source', | ||
| name: 'Trace Source', | ||
| kind: SourceKind.Trace, | ||
| connection: 'clickhouse', | ||
| from: { | ||
| databaseName: 'default', | ||
| tableName: 'traces', | ||
| }, | ||
| timestampValueExpression: 'Timestamp', | ||
| defaultTableSelectExpression: '*', | ||
| durationExpression: 'Duration', | ||
| durationPrecision: 9, | ||
| traceIdExpression: 'TraceId', | ||
| spanIdExpression: 'SpanId', | ||
| parentSpanIdExpression: 'ParentSpanId', | ||
| spanNameExpression: 'SpanName', | ||
| spanKindExpression: 'SpanKind', | ||
| } satisfies TTraceSource; | ||
|
|
||
| const sessionSource = { | ||
| id: 'session-source', | ||
| name: 'Session Source', | ||
| kind: SourceKind.Session, | ||
| connection: 'clickhouse', | ||
| from: { | ||
| databaseName: 'default', | ||
| tableName: 'sessions', | ||
| }, | ||
| timestampValueExpression: 'Timestamp', | ||
| traceSourceId: traceSource.id, | ||
| } satisfies TSessionSource; | ||
|
|
||
| const session = { | ||
| userEmail: 'user@example.com', | ||
| maxTimestamp: '2026-05-21T10:00:00Z', | ||
| minTimestamp: '2026-05-21T09:00:00Z', | ||
| errorCount: '0', | ||
| interactionCount: '0', | ||
| recordingCount: '0', | ||
| serviceName: 'web', | ||
| sessionCount: '12', | ||
| sessionId: 'session-1', | ||
| teamId: 'team-1', | ||
| teamName: 'Team', | ||
| userName: 'User', | ||
| } satisfies Session; | ||
|
|
||
| function renderPanel({ strict = false }: { strict?: boolean } = {}) { | ||
| const panel = ( | ||
| <SessionSidePanel | ||
| traceSource={traceSource} | ||
| sessionSource={sessionSource} | ||
| sessionId="session-1" | ||
| session={session} | ||
| dateRange={[ | ||
| new Date('2026-05-21T09:00:00Z'), | ||
| new Date('2026-05-21T10:00:00Z'), | ||
| ]} | ||
| onClose={jest.fn()} | ||
| /> | ||
| ); | ||
|
|
||
| return renderWithMantine(strict ? <StrictMode>{panel}</StrictMode> : panel); | ||
| } | ||
|
|
||
| describe('SessionSidePanel', () => { | ||
| beforeEach(() => { | ||
| copyTextToClipboardMock.mockResolvedValue(true); | ||
| window.history.pushState( | ||
| {}, | ||
| '', | ||
| '/sessions?sessionSource=source-1&from=1&to=2', | ||
| ); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('copies the current session URL when the share button is clicked', async () => { | ||
| renderPanel(); | ||
|
|
||
| window.history.pushState( | ||
| {}, | ||
| '', | ||
| '/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20', | ||
| ); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /share session/i })); | ||
|
|
||
| await waitFor(() => { | ||
| expect(copyTextToClipboardMock).toHaveBeenCalledWith( | ||
| 'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20', | ||
| ); | ||
| expect(notificationsShowSpy).toHaveBeenCalledWith({ | ||
| color: 'green', | ||
| message: 'Copied link to clipboard', | ||
| }); | ||
| }); | ||
|
|
||
| window.history.pushState( | ||
| {}, | ||
| '', | ||
| '/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=30&sto=40', | ||
| ); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /share session/i })); | ||
|
|
||
| await waitFor(() => { | ||
| expect(copyTextToClipboardMock).toHaveBeenLastCalledWith( | ||
| 'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=30&sto=40', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| it('shows an error notification when copying the session URL fails', async () => { | ||
| copyTextToClipboardMock.mockResolvedValue(false); | ||
| renderPanel(); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /share session/i })); | ||
|
|
||
| await waitFor(() => { | ||
| expect(copyTextToClipboardMock).toHaveBeenCalledWith( | ||
| 'http://localhost/sessions?sessionSource=source-1&from=1&to=2', | ||
| ); | ||
| expect(notificationsShowSpy).toHaveBeenCalledWith({ | ||
| color: 'red', | ||
| message: CLIPBOARD_ERROR_MESSAGE, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| it('shows an error notification when copying the session URL rejects', async () => { | ||
| copyTextToClipboardMock.mockRejectedValue(new Error('clipboard blocked')); | ||
| renderPanel(); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /share session/i })); | ||
|
|
||
| await waitFor(() => { | ||
| expect(copyTextToClipboardMock).toHaveBeenCalledWith( | ||
| 'http://localhost/sessions?sessionSource=source-1&from=1&to=2', | ||
| ); | ||
| expect(notificationsShowSpy).toHaveBeenCalledWith({ | ||
| color: 'red', | ||
| message: CLIPBOARD_ERROR_MESSAGE, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| it('ignores duplicate share clicks while copying is still pending', async () => { | ||
| let finishCopy: (copied: boolean) => void = (_copied: boolean): void => { | ||
| throw new Error('copy promise was not created'); | ||
| }; | ||
| copyTextToClipboardMock.mockImplementation( | ||
| () => | ||
| new Promise(resolve => { | ||
| finishCopy = resolve; | ||
| }), | ||
| ); | ||
| renderPanel(); | ||
|
|
||
| const shareButton = screen.getByRole('button', { name: /share session/i }); | ||
| fireEvent.click(shareButton); | ||
| fireEvent.click(shareButton); | ||
|
|
||
| expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| finishCopy(true); | ||
|
|
||
| await waitFor(() => { | ||
| expect(notificationsShowSpy).toHaveBeenCalledTimes(1); | ||
| expect(notificationsShowSpy).toHaveBeenCalledWith({ | ||
| color: 'green', | ||
| message: 'Copied link to clipboard', | ||
| }); | ||
| }); | ||
|
|
||
| fireEvent.click(shareButton); | ||
|
|
||
| expect(copyTextToClipboardMock).toHaveBeenCalledTimes(2); | ||
|
|
||
| finishCopy(true); | ||
|
|
||
| await waitFor(() => { | ||
| expect(notificationsShowSpy).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); | ||
|
|
||
| it('does not show a share notification after the panel unmounts', async () => { | ||
| let finishCopy: (copied: boolean) => void = (_copied: boolean): void => { | ||
| throw new Error('copy promise was not created'); | ||
| }; | ||
| copyTextToClipboardMock.mockImplementation( | ||
| () => | ||
| new Promise(resolve => { | ||
| finishCopy = resolve; | ||
| }), | ||
| ); | ||
| const { unmount } = renderPanel(); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /share session/i })); | ||
|
|
||
| expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| unmount(); | ||
| finishCopy(true); | ||
| await new Promise(resolve => setTimeout(resolve, 0)); | ||
| expect(notificationsShowSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('shows share notifications when rendered in StrictMode', async () => { | ||
| renderPanel({ strict: true }); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /share session/i })); | ||
|
|
||
| await waitFor(() => { | ||
| expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); | ||
| expect(notificationsShowSpy).toHaveBeenCalledWith({ | ||
| color: 'green', | ||
| message: 'Copied link to clipboard', | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need this ref, I'm not sure what it's guarding against? The state update and call to notifications are ok without it