Skip to content
This repository was archived by the owner on May 13, 2025. It is now read-only.

Commit 793d90c

Browse files
committed
feat: Added line share functionality to JSON view
1 parent c0c0577 commit 793d90c

File tree

3 files changed

+210
-15
lines changed

3 files changed

+210
-15
lines changed

src/pages/Stream/Views/Explore/JSONView.tsx

Lines changed: 178 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Box, Button, Loader, Stack, Text, TextInput } from '@mantine/core';
2-
import { ReactNode, useCallback, useRef, useState } from 'react';
1+
import { Box, Button, Loader, Menu, Stack, Text, TextInput } from '@mantine/core';
2+
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
33
import classes from '../../styles/JSONView.module.css';
44
import EmptyBox from '@/components/Empty';
55
import { ErrorView, LoadingView } from './LoadingViews';
@@ -14,14 +14,23 @@ import { useLogsStore, logsStoreReducers, isJqSearch, formatLogTs } from '../../
1414
import { Log } from '@/@types/parseable/api/query';
1515
import _ from 'lodash';
1616
import jqSearch from '@/utils/jqSearch';
17-
import { IconCheck, IconCopy, IconSearch } from '@tabler/icons-react';
17+
import { IconCheck, IconCopy, IconDotsVertical, IconSearch } from '@tabler/icons-react';
1818
import { copyTextToClipboard } from '@/utils';
1919
import { useStreamStore } from '../../providers/StreamProvider';
2020
import timeRangeUtils from '@/utils/timeRangeUtils';
2121
import { AxiosError } from 'axios';
2222
import { useHotkeys } from '@mantine/hooks';
23+
import { notifySuccess } from '@/utils/notification';
24+
import { isFirstRowInRange, isRowHighlighted } from '../../utils';
2325

24-
const { setInstantSearchValue, applyInstantSearch, applyJqSearch } = logsStoreReducers;
26+
type ContextMenuState = {
27+
visible: boolean;
28+
x: number;
29+
y: number;
30+
row: Log | null;
31+
};
32+
33+
const { setInstantSearchValue, applyInstantSearch, applyJqSearch, setRowNumber, setSelectedLog } = logsStoreReducers;
2534

2635
const Item = (props: { header: string | null; value: string; highlight: boolean }) => {
2736
return (
@@ -75,14 +84,35 @@ const Row = (props: {
7584
log: Log;
7685
searchValue: string;
7786
disableHighlight: boolean;
87+
isRowHighlighted: boolean;
88+
showEllipses: boolean;
89+
setContextMenu: any;
7890
shouldHighlight: (header: string | null, val: number | string | Date | null) => boolean;
7991
}) => {
8092
const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext);
8193
const [fieldTypeMap] = useStreamStore((store) => store.fieldTypeMap);
82-
const { log, disableHighlight, shouldHighlight } = props;
94+
const { log, disableHighlight, shouldHighlight, isRowHighlighted, showEllipses, setContextMenu } = props;
8395

8496
return (
85-
<Stack style={{ flexDirection: 'row' }} className={classes.rowContainer} gap={0}>
97+
<Stack
98+
style={{ flexDirection: 'row', background: isRowHighlighted ? '#E8EDFE' : 'white' }}
99+
className={classes.rowContainer}
100+
gap={0}>
101+
{showEllipses && (
102+
<div
103+
className={classes.actionIconContainer}
104+
onClick={(event) => {
105+
event.stopPropagation();
106+
setContextMenu({
107+
visible: true,
108+
x: event.pageX,
109+
y: event.pageY,
110+
row: log,
111+
});
112+
}}>
113+
<IconDotsVertical stroke={1.2} size={'0.8rem'} color="#545beb" />
114+
</div>
115+
)}
86116
<span>
87117
{_.isObject(log) ? (
88118
_.map(log, (value, key) => {
@@ -112,8 +142,8 @@ const Row = (props: {
112142
);
113143
};
114144

115-
const JsonRows = (props: { isSearching: boolean }) => {
116-
const [{ pageData, instantSearchValue }] = useLogsStore((store) => store.tableOpts);
145+
const JsonRows = (props: { isSearching: boolean; setContextMenu: any }) => {
146+
const [{ pageData, instantSearchValue, rowNumber }, setLogsStore] = useLogsStore((store) => store.tableOpts);
117147
const disableHighlight = props.isSearching || _.isEmpty(instantSearchValue) || isJqSearch(instantSearchValue);
118148
const regExp = disableHighlight ? null : new RegExp(instantSearchValue, 'i');
119149

@@ -124,16 +154,50 @@ const JsonRows = (props: { isSearching: boolean }) => {
124154
[regExp],
125155
);
126156

157+
const handleRowClick = (index: number, event: React.MouseEvent) => {
158+
let newRange = `${index}:${index}`;
159+
160+
if ((event.ctrlKey || event.metaKey) && rowNumber) {
161+
const [start, end] = rowNumber.split(':').map(Number);
162+
const lastIndex = Math.max(start, end);
163+
164+
const startIndex = Math.min(lastIndex, index);
165+
const endIndex = Math.max(lastIndex, index);
166+
newRange = `${startIndex}:${endIndex}`;
167+
setLogsStore((store) => setRowNumber(store, newRange));
168+
} else {
169+
if (rowNumber) {
170+
const [start, end] = rowNumber.split(':').map(Number);
171+
if (index >= start && index <= end) {
172+
setLogsStore((store) => setRowNumber(store, ''));
173+
return;
174+
}
175+
}
176+
177+
setLogsStore((store) => setRowNumber(store, newRange));
178+
}
179+
};
180+
127181
return (
128182
<Stack gap={0} style={{ flex: 1 }}>
129183
{_.map(pageData, (d, index) => (
130-
<Row
131-
log={d}
184+
<div
132185
key={index}
133-
searchValue={instantSearchValue}
134-
disableHighlight={disableHighlight}
135-
shouldHighlight={shouldHighlight}
136-
/>
186+
onClick={(event) => {
187+
event.preventDefault();
188+
handleRowClick(index, event);
189+
}}>
190+
<Row
191+
log={d}
192+
key={index}
193+
searchValue={instantSearchValue}
194+
disableHighlight={disableHighlight}
195+
shouldHighlight={shouldHighlight}
196+
isRowHighlighted={isRowHighlighted(index, rowNumber)}
197+
showEllipses={isFirstRowInRange(index, rowNumber)}
198+
setContextMenu={props.setContextMenu}
199+
/>
200+
</div>
137201
))}
138202
</Stack>
139203
);
@@ -250,13 +314,63 @@ const JsonView = (props: {
250314
isFetchingCount: boolean;
251315
}) => {
252316
const [maximized] = useAppStore((store) => store.maximized);
317+
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
318+
visible: false,
319+
x: 0,
320+
y: 0,
321+
row: null,
322+
});
253323

324+
const contextMenuRef = useRef<HTMLDivElement>(null);
254325
const { errorMessage, hasNoData, showTable, isFetchingCount } = props;
255326
const [isSearching, setSearching] = useState(false);
327+
const [rowNumber, setLogsStore] = useLogsStore((store) => store.tableOpts.rowNumber);
328+
const [pageData] = useLogsStore((store) => store.tableOpts.pageData);
329+
const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext);
256330
const primaryHeaderHeight = !maximized
257331
? PRIMARY_HEADER_HEIGHT + STREAM_PRIMARY_TOOLBAR_CONTAINER_HEIGHT + STREAM_SECONDARY_TOOLBAR_HRIGHT
258332
: 0;
259333

334+
useEffect(() => {
335+
const handleClickOutside = (event: MouseEvent) => {
336+
if (contextMenuRef.current && !contextMenuRef.current.contains(event.target as Node)) {
337+
closeContextMenu();
338+
}
339+
};
340+
341+
if (contextMenu.visible) {
342+
document.addEventListener('mousedown', handleClickOutside);
343+
}
344+
345+
return () => {
346+
document.removeEventListener('mousedown', handleClickOutside);
347+
};
348+
}, [contextMenu.visible]);
349+
350+
const closeContextMenu = () => setContextMenu({ visible: false, x: 0, y: 0, row: null });
351+
352+
const selectLog = useCallback((log: Log | null) => {
353+
if (!log) return;
354+
const selectedText = window.getSelection()?.toString();
355+
if (selectedText !== undefined && selectedText?.length > 0) return;
356+
357+
setLogsStore((store) => setSelectedLog(store, log));
358+
}, []);
359+
360+
const copyUrl = useCallback(() => {
361+
copyTextToClipboard(window.location.href);
362+
notifySuccess({ message: 'Link Copied!' });
363+
}, [window.location.href]);
364+
365+
const copyJSON = useCallback(() => {
366+
const [start, end] = rowNumber.split(':').map(Number);
367+
368+
const rowsToCopy = pageData.slice(start, end + 1);
369+
370+
copyTextToClipboard(rowsToCopy);
371+
notifySuccess({ message: 'JSON Copied!' });
372+
}, [rowNumber]);
373+
260374
return (
261375
<TableContainer>
262376
<Toolbar isSearching={isSearching} setSearching={setSearching} />
@@ -268,10 +382,59 @@ const JsonView = (props: {
268382
style={{ display: 'flex', flexDirection: 'row', maxHeight: `calc(100vh - ${primaryHeaderHeight}px )` }}>
269383
<Stack gap={0}>
270384
<Stack style={{ overflowY: 'scroll' }}>
271-
<JsonRows isSearching={isSearching} />
385+
<JsonRows isSearching={isSearching} setContextMenu={setContextMenu} />
272386
</Stack>
273387
</Stack>
274388
</Box>
389+
{contextMenu.visible && (
390+
<div
391+
ref={contextMenuRef}
392+
style={{
393+
top: contextMenu.y,
394+
left: contextMenu.x,
395+
}}
396+
className={classes.contextMenuContainer}
397+
onClick={closeContextMenu}>
398+
<Menu opened={contextMenu.visible} onClose={closeContextMenu}>
399+
{(() => {
400+
const [start, end] = rowNumber.split(':').map(Number);
401+
const rowCount = end - start + 1;
402+
403+
if (rowCount === 1) {
404+
return (
405+
<Menu.Item
406+
onClick={() => {
407+
selectLog(contextMenu.row);
408+
closeContextMenu();
409+
}}>
410+
View JSON
411+
</Menu.Item>
412+
);
413+
}
414+
415+
return null;
416+
})()}
417+
{isSecureHTTPContext && (
418+
<>
419+
<Menu.Item
420+
onClick={() => {
421+
copyJSON();
422+
closeContextMenu();
423+
}}>
424+
Copy JSON
425+
</Menu.Item>
426+
<Menu.Item
427+
onClick={() => {
428+
copyUrl();
429+
closeContextMenu();
430+
}}>
431+
Copy permalink
432+
</Menu.Item>
433+
</>
434+
)}
435+
</Menu>
436+
</div>
437+
)}
275438
</Box>
276439
) : hasNoData ? (
277440
<>

src/pages/Stream/styles/JSONView.module.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414
overflow: hidden;
1515
}
1616

17+
.actionIconContainer {
18+
position: absolute;
19+
left: 0px;
20+
cursor: pointer;
21+
padding: 1px 0px;
22+
border-radius: 4px;
23+
background-color: white;
24+
border: 0.8px solid #545beb;
25+
display: flex;
26+
}
27+
1728
.rowContainer {
1829
border-bottom: 1px solid var(--mantine-color-gray-1);
1930
padding: 0.25rem 1rem;
@@ -66,3 +77,12 @@
6677
justify-content: space-between;
6778
gap: 10px;
6879
}
80+
81+
.contextMenuContainer {
82+
position: fixed;
83+
z-index: 1000;
84+
background-color: white;
85+
border: 1px solid #ccc;
86+
border-radius: 4px;
87+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
88+
}

src/pages/Stream/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,15 @@ export const genColumnsToShow = (opts: {
7474
];
7575
return headers.filter((header) => !columnsToIgnore.includes(header));
7676
};
77+
78+
export const isRowHighlighted = (index: number, rowNumber: string) => {
79+
if (!rowNumber) return false;
80+
const [start, end] = rowNumber.split(':').map(Number);
81+
return index >= start && index <= end;
82+
};
83+
84+
export const isFirstRowInRange = (index: number, rowNumber: string) => {
85+
if (!rowNumber) return false;
86+
const [start] = rowNumber.split(':').map(Number);
87+
return index === start;
88+
};

0 commit comments

Comments
 (0)