Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/clipboard-http-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

Fix copy buttons silently failing when HyperDX is served over plain HTTP. Add a `document.execCommand('copy')` fallback for non-secure contexts and a clear toast when both paths fail.
38 changes: 27 additions & 11 deletions packages/app/src/components/DBRowJsonViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, screen, within } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';

import { buildJSONExtractQuery, DBRowJsonViewer } from './DBRowJsonViewer';
import { RowSidePanelContext } from './DBRowSidePanel';
Expand Down Expand Up @@ -159,40 +159,56 @@ describe('DBRowJsonViewer', () => {
const mockClipboard = jest.fn();

beforeEach(() => {
// copyTextToClipboard short-circuits to the execCommand fallback unless
// both `window.isSecureContext` is true AND `navigator.clipboard.writeText`
// exists, so seed both for these unit tests.
Object.defineProperty(window, 'isSecureContext', {
value: true,
writable: true,
configurable: true,
});
mockClipboard.mockReset();
mockClipboard.mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: mockClipboard },
});
});

it('copies array elements from expanded stringified JSON', () => {
it('copies array elements from expanded stringified JSON', async () => {
const arrayObject = { status: 'True', type: 'PodReady' };
const data = { conditions: JSON.stringify([arrayObject]) };

renderComponent(data);
expandAndClickButton('conditions', '0', 'Copy Object');

expect(mockClipboard).toHaveBeenCalledWith(
JSON.stringify(arrayObject, null, 2),
);
await waitFor(() => {
expect(mockClipboard).toHaveBeenCalledWith(
JSON.stringify(arrayObject, null, 2),
);
});
});

it('copies entire stringified value when not expanded', () => {
it('copies entire stringified value when not expanded', async () => {
const arrayData = [{ type: 'Ready' }, { type: 'Init' }];
const data = { conditions: JSON.stringify(arrayData) };

renderComponent(data);
clickLineButton('conditions', 'Copy Value');

expect(mockClipboard).toHaveBeenCalledWith(JSON.stringify(arrayData));
await waitFor(() => {
expect(mockClipboard).toHaveBeenCalledWith(JSON.stringify(arrayData));
});
});

it('copies regular nested objects', () => {
it('copies regular nested objects', async () => {
renderComponent(logData);
clickLineButton('nested', 'Copy Object');

expect(mockClipboard).toHaveBeenCalledWith(
JSON.stringify({ field3: 'nested value' }, null, 2),
);
await waitFor(() => {
expect(mockClipboard).toHaveBeenCalledWith(
JSON.stringify({ field3: 'nested value' }, null, 2),
);
});
});
});

Expand Down
23 changes: 8 additions & 15 deletions packages/app/src/components/DBRowJsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {

import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { mergePath } from '@/utils';
import { copyTextWithToast } from '@/utils/clipboard';

type JSONExtractFn =
| 'JSONExtractString'
Expand Down Expand Up @@ -219,16 +220,14 @@ function HyperJsonMenu({ rowData }: { rowData: any }) {
<Group>
{rowData != null && (
<UnstyledButton
data-testid="json-viewer-copy-row"
onClick={() => {
window.navigator.clipboard.writeText(
void copyTextWithToast(
typeof rowData === 'string'
? rowData
: JSON.stringify(rowData, null, 2),
'Value copied to clipboard',
);
notifications.show({
color: 'green',
message: `Value copied to clipboard`,
});
}}
variant="copy"
title={'Copy row as JSON'}
Expand Down Expand Up @@ -559,13 +558,10 @@ export function DBRowJsonViewer({
copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath);
}

window.navigator.clipboard.writeText(
void copyTextWithToast(
JSON.stringify(copiedObj, null, 2),
'Copied object to clipboard',
);
notifications.show({
color: 'green',
message: `Copied object to clipboard`,
});
};

if (typeof value === 'object') {
Expand All @@ -584,15 +580,12 @@ export function DBRowJsonViewer({
</Group>
),
onClick: () => {
window.navigator.clipboard.writeText(
void copyTextWithToast(
typeof value === 'string'
? value
: JSON.stringify(value, null, 2),
'Value copied to clipboard',
);
notifications.show({
color: 'green',
message: `Value copied to clipboard`,
});
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Popover } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';

import { copyTextWithToast } from '@/utils/clipboard';

import { RowSidePanelContext } from '../DBRowSidePanel';

import { DBRowTableIconButton } from './DBRowTableIconButton';
Expand Down Expand Up @@ -32,16 +34,27 @@ const DBRowTableFieldWithPopover = ({
const [hoverDisabled, setHoverDisabled] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const hoverDisableTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);

// Cleanup timeouts on unmount to prevent memory leaks
// Tracks the post-copy "isCopied -> isCopied=false" reset so unmount can
// cancel it (popover and its parent row are virtualised; either can recycle
// mid-await).
const copyResetTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isMountedRef = useRef(true);

// Cleanup timeouts on unmount to prevent memory leaks and setState-after-
// unmount warnings.
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hoverDisableTimeoutRef.current) {
clearTimeout(hoverDisableTimeoutRef.current);
}
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
};
}, []);

Expand Down Expand Up @@ -83,16 +96,19 @@ const DBRowTableFieldWithPopover = ({
};

const copyFieldValue = async () => {
try {
const value =
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
await navigator.clipboard.writeText(value);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Optionally show an error toast notification to the user
const value =
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
const ok = await copyTextWithToast(value, 'Copied field value');
if (!ok || !isMountedRef.current) return;
setIsCopied(true);
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
copyResetTimeoutRef.current = setTimeout(() => {
if (isMountedRef.current) {
setIsCopied(false);
}
}, 2000);
};

const addFilter = () => {
Expand Down Expand Up @@ -160,6 +176,7 @@ const DBRowTableFieldWithPopover = ({
tabIndex={-1}
aria-hidden="true"
style={{ cursor: 'pointer' }}
data-testid="field-popover-trigger"
>
{children}
</span>
Expand All @@ -182,6 +199,7 @@ const DBRowTableFieldWithPopover = ({
variant="copy"
isActive={isCopied}
title={isCopied ? 'Copied!' : 'Copy field value'}
data-testid="field-copy-value-button"
>
<IconCopy size={14} />
</DBRowTableIconButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface DBRowTableIconButtonProps {
variant?: 'copy' | 'default';
isActive?: boolean;
iconSize?: number;
'data-testid'?: string;
}

export const DBRowTableIconButton: React.FC<DBRowTableIconButtonProps> = ({
Expand All @@ -25,6 +26,7 @@ export const DBRowTableIconButton: React.FC<DBRowTableIconButtonProps> = ({
variant = 'default',
isActive = false,
iconSize = 14,
'data-testid': dataTestId,
}) => {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
Expand Down Expand Up @@ -55,6 +57,7 @@ export const DBRowTableIconButton: React.FC<DBRowTableIconButtonProps> = ({
className={baseClasses}
tabIndex={tabIndex}
role="button"
data-testid={dataTestId}
>
{isActive ? <IconCheck size={iconSize} /> : children}
</UnstyledButton>
Expand Down
107 changes: 62 additions & 45 deletions packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react';

import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere';
import { copyTextWithToast } from '@/utils/clipboard';

import { DBRowTableIconButton } from './DBRowTableIconButton';

Expand All @@ -24,60 +25,74 @@ const DBRowTableRowButtons: React.FC<DBRowTableRowButtonsProps> = ({
}) => {
const [isCopied, setIsCopied] = useState(false);
const [isUrlCopied, setIsUrlCopied] = useState(false);
// Rows are virtualised and recycle on scroll; cancel pending state resets on
// unmount so we don't setState on a recycled row.
const copyResetRef = useRef<NodeJS.Timeout | undefined>(undefined);
const urlCopyResetRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isMountedRef = useRef(true);

const copyRowData = async () => {
try {
// Filter out internal metadata fields that start with __ or are generated IDs
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (copyResetRef.current) clearTimeout(copyResetRef.current);
if (urlCopyResetRef.current) clearTimeout(urlCopyResetRef.current);
};
}, []);

const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row;
const copyRowData = async () => {
// Filter out internal metadata fields that start with __ or are generated IDs
const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row;

// Parse JSON string fields to make them proper JSON objects
const parsedRow = Object.entries(cleanRow).reduce(
(acc, [key, value]) => {
if (
typeof value === 'string' &&
(value.startsWith('{') || value.startsWith('['))
) {
try {
acc[key] = JSON.parse(value);
} catch {
// If parsing fails, keep the original string
acc[key] = value;
}
} else {
// Parse JSON string fields to make them proper JSON objects
const parsedRow = Object.entries(cleanRow).reduce(
(acc, [key, value]) => {
if (
typeof value === 'string' &&
(value.startsWith('{') || value.startsWith('['))
) {
try {
acc[key] = JSON.parse(value);
} catch {
// If parsing fails, keep the original string
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
} else {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);

const rowData = JSON.stringify(parsedRow, null, 2);
await navigator.clipboard.writeText(rowData);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (error) {
console.error('Failed to copy row data to clipboard:', error);
// Optionally show an error toast notification to the user
}
const rowData = JSON.stringify(parsedRow, null, 2);
const ok = await copyTextWithToast(rowData, 'Copied row as JSON');
if (!ok || !isMountedRef.current) return;
setIsCopied(true);
if (copyResetRef.current) clearTimeout(copyResetRef.current);
copyResetRef.current = setTimeout(() => {
if (isMountedRef.current) setIsCopied(false);
}, 2000);
};

const copyRowUrl = async () => {
try {
const rowWhereResult = getRowWhere(row);
const currentUrl = new URL(window.location.href);
// Add the row identifier as query parameters
currentUrl.searchParams.set('rowWhere', rowWhereResult.where);
if (sourceId) {
currentUrl.searchParams.set('rowSource', sourceId);
}
await navigator.clipboard.writeText(currentUrl.toString());
setIsUrlCopied(true);
setTimeout(() => setIsUrlCopied(false), 2000);
} catch (error) {
console.error('Failed to copy URL to clipboard:', error);
// Optionally show an error toast notification to the user
const rowWhereResult = getRowWhere(row);
const currentUrl = new URL(window.location.href);
// Add the row identifier as query parameters
currentUrl.searchParams.set('rowWhere', rowWhereResult.where);
if (sourceId) {
currentUrl.searchParams.set('rowSource', sourceId);
}
const ok = await copyTextWithToast(
currentUrl.toString(),
'Copied shareable link',
);
if (!ok || !isMountedRef.current) return;
setIsUrlCopied(true);
if (urlCopyResetRef.current) clearTimeout(urlCopyResetRef.current);
urlCopyResetRef.current = setTimeout(() => {
if (isMountedRef.current) setIsUrlCopied(false);
}, 2000);
};

return (
Expand All @@ -98,6 +113,7 @@ const DBRowTableRowButtons: React.FC<DBRowTableRowButtonsProps> = ({
title={
isCopied ? 'Copied entire row as JSON!' : 'Copy entire row as JSON'
}
data-testid="row-copy-json-button"
>
<IconCopy size={16} />
</DBRowTableIconButton>
Expand All @@ -110,6 +126,7 @@ const DBRowTableRowButtons: React.FC<DBRowTableRowButtonsProps> = ({
? 'Copied shareable link!'
: 'Copy shareable link to this specific row'
}
data-testid="row-copy-link-button"
>
<IconLink size={16} />
</DBRowTableIconButton>
Expand Down
Loading
Loading