Skip to content

Commit 2664060

Browse files
authored
[FEATURE] LogsTable: Improve copy/pasting from logstable panel (perses#523)
* feat(logstable): add copy helper utilities for formatting log entries Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com> * feat(logstable): Add copy functionality This commit introduces two distinct ways to copy log lines: Single line copy adds a copy button on the selected line, where a drop-down allows users to pick either the full log, just the log message, or the full, JSON-formatted log including the labels. This enhancement is necessary because currently, copying lines means that the rich copy contains a lot of formatting strings. In addition, this commit introduces multiline copying, which solves the problem that copying multiple log row entries polluted the clipboard entry with rich HTML. Now, users can select multiple log row entries by Cmd/Ctrl- clicking log rows, or select entire ranges by shift-clicking. The `Esc` key allows to reset the selection. There is also a hint banner at the top of the logs table. It can be hidden, which is saved to local storage. Multi-row selection shows a toast in a popover, which then also allows changing the format in which the logs are copied, same as single line copy. Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com> * Remove unnecessary test Removed the undefined labels test. Since labels is part of the LogEntry type contract and all real logs have labels defined (even if empty), testing the undefined case was testing an edge case that violates the type contract. The empty labels test (labels: {}) covers that scenario. Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com> * Run `npm install` Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com> --------- Signed-off-by: Jeremy Rickards <jeremy.rickards@sap.com>
1 parent 8c1c6b5 commit 2664060

8 files changed

Lines changed: 1059 additions & 124 deletions

File tree

logstable/src/LogsTablePanel.test.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@
1212
// limitations under the License.
1313

1414
import { ChartsProvider, SnackbarProvider, testChartsTheme } from '@perses-dev/components';
15-
import { render, screen } from '@testing-library/react';
15+
import { render, screen, fireEvent } from '@testing-library/react';
1616
import { MOCK_LOGS_QUERY_RESULT, MOCK_LOGS_QUERY_DEFINITION } from './test/mock-query-results';
1717
import { LogsTablePanel } from './LogsTablePanel';
1818
import { LogsQueryData, LogsTableProps } from './model';
1919

20+
// Mock clipboard API
21+
Object.assign(navigator, {
22+
clipboard: {
23+
writeText: jest.fn(() => Promise.resolve()),
24+
},
25+
});
26+
2027
const TEST_LOGS_TABLE_PROPS: Omit<LogsTableProps, 'queryResults'> = {
2128
contentDimensions: {
2229
width: 500,
@@ -42,6 +49,10 @@ describe('LogsTablePanel', () => {
4249
);
4350
};
4451

52+
beforeEach(() => {
53+
jest.clearAllMocks();
54+
});
55+
4556
it('should render multi values with timestamps', async () => {
4657
renderPanel(MOCK_LOGS_QUERY_RESULT);
4758
const items = screen.getByTestId('virtuoso-item-list');
@@ -50,4 +61,49 @@ describe('LogsTablePanel', () => {
5061
expect(await screen.findAllByText('2022-10-24T15:31:30.000Z')).toHaveLength(1); // first timestamp appear once per line
5162
expect(await screen.findAllByText('2022-10-24T15:31:31.000Z')).toHaveLength(1); // second timestamp appear once per line
5263
});
64+
65+
it('should select multiple rows with Cmd+Click', () => {
66+
renderPanel(MOCK_LOGS_QUERY_RESULT);
67+
const items = screen.getByTestId('virtuoso-item-list');
68+
const firstRow = items.querySelector('[data-log-index="0"] > div')!;
69+
const secondRow = items.querySelector('[data-log-index="1"] > div')!;
70+
71+
// Get computed background before selection
72+
const firstRowInitialBg = window.getComputedStyle(firstRow).backgroundColor;
73+
74+
// Cmd+Click first row
75+
fireEvent.mouseDown(firstRow, { metaKey: true });
76+
77+
// Background should change (selected state)
78+
const firstRowSelectedBg = window.getComputedStyle(firstRow).backgroundColor;
79+
expect(firstRowSelectedBg).not.toBe(firstRowInitialBg);
80+
81+
// Cmd+Click second row
82+
fireEvent.mouseDown(secondRow, { metaKey: true });
83+
84+
// Both should still be selected
85+
expect(window.getComputedStyle(firstRow).backgroundColor).toBe(firstRowSelectedBg);
86+
expect(window.getComputedStyle(secondRow).backgroundColor).toBe(firstRowSelectedBg);
87+
});
88+
89+
it('should copy multiple selected rows with Cmd+C', () => {
90+
renderPanel(MOCK_LOGS_QUERY_RESULT);
91+
const items = screen.getByTestId('virtuoso-item-list');
92+
const firstRow = items.querySelector('[data-log-index="0"] > div')!;
93+
const secondRow = items.querySelector('[data-log-index="1"] > div')!;
94+
95+
// Select both rows
96+
fireEvent.mouseDown(firstRow, { metaKey: true });
97+
fireEvent.mouseDown(secondRow, { metaKey: true });
98+
99+
// Copy with onCopy event
100+
const virtuosoScroller = screen.getByTestId('virtuoso-scroller');
101+
const mockClipboardData = {
102+
setData: jest.fn(),
103+
};
104+
fireEvent.copy(virtuosoScroller, { clipboardData: mockClipboardData });
105+
106+
// Should have copied both logs
107+
expect(mockClipboardData.setData).toHaveBeenCalledWith('text/plain', expect.stringMatching(/foo.*bar/s));
108+
});
53109
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
15+
import { LogEntry } from '@perses-dev/core';
16+
import { LogRow } from './LogRow';
17+
18+
// Mock clipboard API
19+
Object.assign(navigator, {
20+
clipboard: {
21+
writeText: jest.fn(() => Promise.resolve()),
22+
},
23+
});
24+
25+
describe('LogRow', () => {
26+
const mockLog: LogEntry = {
27+
timestamp: 1767225600,
28+
line: 'foo bar baz',
29+
labels: { level: 'info', service: 'foo', region: 'bar' },
30+
};
31+
32+
const renderLogRow = ({ onSelect = jest.fn(), isSelected = false } = {}) => {
33+
return render(
34+
<LogRow
35+
log={mockLog}
36+
index={0}
37+
isExpanded={false}
38+
onToggle={jest.fn()}
39+
isSelected={isSelected}
40+
onSelect={onSelect}
41+
/>
42+
);
43+
};
44+
45+
beforeEach(() => {
46+
jest.clearAllMocks();
47+
});
48+
49+
it('should render log message', () => {
50+
renderLogRow();
51+
expect(screen.getByText('foo bar baz')).toBeInTheDocument();
52+
});
53+
54+
it('should show copy button', () => {
55+
renderLogRow();
56+
expect(screen.getByLabelText(/Copy log/i)).toBeInTheDocument();
57+
});
58+
59+
describe('copy formats', () => {
60+
const openCopyMenu = async () => {
61+
const { container } = renderLogRow();
62+
const row = container.querySelector('[data-log-index="0"]')!;
63+
64+
fireEvent.mouseEnter(row);
65+
const copyButton = await screen.findByLabelText(/Copy log/i);
66+
fireEvent.click(copyButton);
67+
68+
return { container };
69+
};
70+
71+
it('should copy log in Full format', async () => {
72+
await openCopyMenu();
73+
74+
const formatOption = await screen.findByText(/^Copy log$/i);
75+
fireEvent.click(formatOption);
76+
77+
await waitFor(() => {
78+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('level="info"'));
79+
});
80+
});
81+
82+
it('should copy log in Message format', async () => {
83+
await openCopyMenu();
84+
85+
const formatOption = await screen.findByText(/^Copy message$/i);
86+
fireEvent.click(formatOption);
87+
88+
await waitFor(() => {
89+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('foo bar baz');
90+
});
91+
});
92+
93+
it('should copy log in JSON format', async () => {
94+
await openCopyMenu();
95+
96+
const formatOption = await screen.findByText(/^Copy as JSON$/i);
97+
fireEvent.click(formatOption);
98+
99+
await waitFor(() => {
100+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('"timestamp"'));
101+
});
102+
});
103+
});
104+
105+
it('should show success checkmark after copying', async () => {
106+
const { container } = renderLogRow();
107+
const row = container.querySelector('[data-log-index="0"]')!;
108+
109+
fireEvent.mouseEnter(row);
110+
const copyButton = await screen.findByLabelText(/Copy log/i);
111+
fireEvent.click(copyButton);
112+
113+
const messageOption = await screen.findByText(/^Copy message$/i);
114+
fireEvent.click(messageOption);
115+
116+
await waitFor(() => {
117+
expect(screen.getByTestId('CheckIcon')).toBeInTheDocument();
118+
});
119+
});
120+
121+
it('should handle selection click', () => {
122+
const onSelect = jest.fn();
123+
const { container } = renderLogRow({ onSelect });
124+
const content = container.querySelector('[data-log-index="0"] > div')!;
125+
126+
fireEvent.mouseDown(content);
127+
128+
expect(onSelect).toHaveBeenCalledWith(0, expect.objectContaining({ type: 'mousedown' }));
129+
});
130+
});

0 commit comments

Comments
 (0)