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

Commit 7900103

Browse files
authored
feat: add line share feature (#388)
1 parent fd76cd4 commit 7900103

File tree

5 files changed

+300
-72
lines changed

5 files changed

+300
-72
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import useMountedState from '@/hooks/useMountedState';
1010
import classes from '../../styles/Footer.module.css';
1111
import { LOGS_FOOTER_HEIGHT } from '@/constants/theme';
1212

13-
const { setPageAndPageData, setCurrentPage, setCurrentOffset } = logsStoreReducers;
13+
const { setPageAndPageData, setCurrentPage, setCurrentOffset, setRowNumber } = logsStoreReducers;
1414

1515
const TotalCount = (props: { totalCount: number }) => {
1616
return (
@@ -93,6 +93,7 @@ const Footer = (props: { loaded: boolean; hasNoData: boolean; isFetchingCount: b
9393
const { totalPages, currentOffset, currentPage, perPage, totalCount } = tableOpts;
9494

9595
const onPageChange = useCallback((page: number) => {
96+
setLogsStore((store) => setRowNumber(store, ''));
9697
setLogsStore((store) => setPageAndPageData(store, page));
9798
}, []);
9899

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

Lines changed: 237 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Box } from '@mantine/core';
2-
import { useCallback, useMemo } from 'react';
3-
import type { ReactNode } from 'react';
1+
import { Box, Menu } from '@mantine/core';
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3+
import type { Dispatch, ReactNode, SetStateAction } from 'react';
44
import EmptyBox from '@/components/Empty';
55
import FilterPills from '../../components/FilterPills';
66
import tableStyles from '../../styles/Logs.module.css';
@@ -21,8 +21,11 @@ import { Log } from '@/@types/parseable/api/query';
2121
import { CopyIcon } from './JSONView';
2222
import { FieldTypeMap, useStreamStore } from '../../providers/StreamProvider';
2323
import timeRangeUtils from '@/utils/timeRangeUtils';
24+
import { IconDotsVertical } from '@tabler/icons-react';
25+
import { copyTextToClipboard } from '@/utils';
26+
import { notifySuccess } from '@/utils/notification';
2427

25-
const { setSelectedLog } = logsStoreReducers;
28+
const { setSelectedLog, setRowNumber } = logsStoreReducers;
2629
const TableContainer = (props: { children: ReactNode }) => {
2730
return <Box className={tableStyles.container}>{props.children}</Box>;
2831
};
@@ -54,10 +57,23 @@ const getSanitizedValue = (value: CellType, isTimestamp: boolean) => {
5457
return String(value);
5558
};
5659

57-
const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTypeMap: FieldTypeMap) => {
60+
type ContextMenuState = {
61+
visible: boolean;
62+
x: number;
63+
y: number;
64+
row: Log | null;
65+
};
66+
67+
const makeHeaderOpts = (
68+
headers: string[],
69+
isSecureHTTPContext: boolean,
70+
fieldTypeMap: FieldTypeMap,
71+
rowNumber: string,
72+
setContextMenu: Dispatch<SetStateAction<ContextMenuState>>,
73+
) => {
5874
return _.reduce(
5975
headers,
60-
(acc: { accessorKey: string; header: string; grow: boolean }[], header) => {
76+
(acc: { accessorKey: string; header: string; grow: boolean }[], header, index) => {
6177
const isTimestamp = _.get(fieldTypeMap, header, null) === 'timestamp';
6278

6379
return [
@@ -76,8 +92,38 @@ const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTy
7692
})
7793
.value();
7894
const sanitizedValue = getSanitizedValue(value, isTimestamp);
95+
let isFirstSelectedRow = false;
96+
if (rowNumber) {
97+
const [start] = rowNumber.split(':').map(Number);
98+
isFirstSelectedRow = cell.row.index === start;
99+
}
100+
const isFirstColumn = index === 0;
79101
return (
80-
<div className={tableStyles.customCellContainer} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
102+
<div
103+
className={tableStyles.customCellContainer}
104+
style={{
105+
marginLeft: isFirstSelectedRow && isFirstColumn ? '4px' : '',
106+
overflow: 'hidden',
107+
textOverflow: 'ellipsis',
108+
}}>
109+
<div
110+
className={tableStyles.actionIconContainer}
111+
onClick={(event) => {
112+
event.stopPropagation();
113+
setContextMenu({
114+
visible: true,
115+
x: event.pageX,
116+
y: event.pageY,
117+
row: cell.row.original,
118+
});
119+
}}
120+
style={{
121+
display: isFirstSelectedRow && isFirstColumn ? 'flex' : '',
122+
}}>
123+
{isSecureHTTPContext
124+
? sanitizedValue && <IconDotsVertical stroke={1.2} size={'0.8rem'} color="#545beb" />
125+
: null}
126+
</div>
81127
{sanitizedValue}
82128
<div className={tableStyles.copyIconContainer}>
83129
{isSecureHTTPContext ? sanitizedValue && <CopyIcon value={sanitizedValue} /> : null}
@@ -91,19 +137,31 @@ const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTy
91137
[],
92138
);
93139
};
94-
95140
const makeColumnVisiblityOpts = (columns: string[]) => {
96141
return _.reduce(columns, (acc, column) => ({ ...acc, [column]: false }), {});
97142
};
98143

99144
const Table = (props: { primaryHeaderHeight: number }) => {
100-
const [{ orderedHeaders, disabledColumns, pinnedColumns, pageData, wrapDisabledColumns }, setLogsStore] =
101-
useLogsStore((store) => store.tableOpts);
145+
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
146+
visible: false,
147+
x: 0,
148+
y: 0,
149+
row: null,
150+
});
151+
152+
const contextMenuRef = useRef<HTMLDivElement>(null);
153+
const [{ orderedHeaders, disabledColumns, pageData, wrapDisabledColumns, rowNumber }, setLogsStore] = useLogsStore(
154+
(store) => store.tableOpts,
155+
);
102156
const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext);
103157
const [fieldTypeMap] = useStreamStore((store) => store.fieldTypeMap);
104-
const columns = useMemo(() => makeHeaderOpts(orderedHeaders, isSecureHTTPContext, fieldTypeMap), [orderedHeaders]);
158+
const columns = useMemo(
159+
() => makeHeaderOpts(orderedHeaders, isSecureHTTPContext, fieldTypeMap, rowNumber, setContextMenu),
160+
[orderedHeaders, rowNumber],
161+
);
105162
const columnVisibility = useMemo(() => makeColumnVisiblityOpts(disabledColumns), [disabledColumns, orderedHeaders]);
106-
const selectLog = useCallback((log: Log) => {
163+
const selectLog = useCallback((log: Log | null) => {
164+
if (!log) return;
107165
const selectedText = window.getSelection()?.toString();
108166
if (selectedText !== undefined && selectedText?.length > 0) return;
109167

@@ -126,67 +184,179 @@ const Table = (props: { primaryHeaderHeight: number }) => {
126184
},
127185
[wrapDisabledColumns],
128186
);
187+
useEffect(() => {
188+
const handleClickOutside = (event: MouseEvent) => {
189+
if (contextMenuRef.current && !contextMenuRef.current.contains(event.target as Node)) {
190+
closeContextMenu();
191+
}
192+
};
193+
194+
if (contextMenu.visible) {
195+
document.addEventListener('mousedown', handleClickOutside);
196+
}
197+
198+
return () => {
199+
document.removeEventListener('mousedown', handleClickOutside);
200+
};
201+
}, [contextMenu.visible]);
202+
203+
const closeContextMenu = () => setContextMenu({ visible: false, x: 0, y: 0, row: null });
204+
205+
const copyUrl = useCallback(() => {
206+
copyTextToClipboard(window.location.href);
207+
notifySuccess({ message: 'Link Copied!' });
208+
}, [window.location.href]);
209+
210+
const copyJSON = useCallback(() => {
211+
const [start, end] = rowNumber.split(':').map(Number);
212+
213+
const rowsToCopy = pageData.slice(start, end + 1);
214+
215+
copyTextToClipboard(rowsToCopy);
216+
notifySuccess({ message: 'JSON Copied!' });
217+
}, [rowNumber]);
218+
219+
const handleRowClick = (index: number, event: React.MouseEvent) => {
220+
let newRange = `${index}:${index}`;
221+
222+
if ((event.ctrlKey || event.metaKey) && rowNumber) {
223+
const [start, end] = rowNumber.split(':').map(Number);
224+
const lastIndex = Math.max(start, end);
225+
226+
const startIndex = Math.min(lastIndex, index);
227+
const endIndex = Math.max(lastIndex, index);
228+
newRange = `${startIndex}:${endIndex}`;
229+
setLogsStore((store) => setRowNumber(store, newRange));
230+
} else {
231+
if (rowNumber) {
232+
const [start, end] = rowNumber.split(':').map(Number);
233+
if (index >= start && index <= end) {
234+
setLogsStore((store) => setRowNumber(store, ''));
235+
return;
236+
}
237+
}
238+
239+
setLogsStore((store) => setRowNumber(store, newRange));
240+
}
241+
};
129242

130243
return (
131-
<MantineReactTable
132-
enableBottomToolbar={false}
133-
enableTopToolbar={false}
134-
enableColumnResizing={true}
135-
mantineTableBodyCellProps={({ column: { id } }) => makeCellCustomStyles(id)}
136-
mantineTableHeadRowProps={{ style: { border: 'none' } }}
137-
mantineTableHeadCellProps={{
138-
style: {
139-
fontWeight: 600,
140-
fontSize: '0.65rem',
141-
border: 'none',
142-
padding: '0.5rem 1rem',
143-
},
144-
}}
145-
mantineTableBodyRowProps={({ row }) => {
146-
return {
147-
onClick: () => {
148-
selectLog(row.original);
244+
<>
245+
<MantineReactTable
246+
enableBottomToolbar={false}
247+
enableTopToolbar={false}
248+
enableColumnResizing
249+
mantineTableBodyCellProps={({ column: { id } }) => makeCellCustomStyles(id)}
250+
mantineTableHeadRowProps={{ style: { border: 'none' } }}
251+
mantineTableHeadCellProps={{
252+
style: {
253+
fontWeight: 600,
254+
fontSize: '0.65rem',
255+
border: 'none',
256+
padding: '0.5rem 1rem',
149257
},
258+
}}
259+
mantineTableBodyRowProps={({ row }) => {
260+
return {
261+
onClick: (event) => {
262+
event.preventDefault();
263+
handleRowClick(row.index, event);
264+
},
265+
style: {
266+
border: 'none',
267+
background: row.index % 2 === 0 ? '#f8f9fa' : 'white',
268+
backgroundColor:
269+
rowNumber &&
270+
(() => {
271+
const [start, end] = rowNumber.split(':').map(Number);
272+
return row.index >= start && row.index <= end;
273+
})()
274+
? '#E8EDFE'
275+
: '',
276+
},
277+
};
278+
}}
279+
mantineTableProps={{ highlightOnHover: false }}
280+
mantineTableHeadProps={{
150281
style: {
151282
border: 'none',
152-
background: row.index % 2 === 0 ? '#f8f9fa' : 'white',
153283
},
154-
};
155-
}}
156-
mantineTableHeadProps={{
157-
style: {
158-
border: 'none',
159-
},
160-
}}
161-
columns={columns}
162-
data={pageData}
163-
mantinePaperProps={{ style: { border: 'none' } }}
164-
enablePagination={false}
165-
enableColumnPinning={true}
166-
initialState={{
167-
columnPinning: {
168-
left: pinnedColumns,
169-
},
170-
}}
171-
enableStickyHeader={true}
172-
defaultColumn={{ minSize: 100 }}
173-
layoutMode="grid"
174-
state={{
175-
columnPinning: {
176-
left: pinnedColumns,
177-
},
178-
columnVisibility,
179-
columnOrder: orderedHeaders,
180-
}}
181-
mantineTableContainerProps={{
182-
style: {
183-
height: `calc(100vh - ${props.primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`,
184-
},
185-
}}
186-
renderColumnActionsMenuItems={({ column }) => {
187-
return <Column columnName={column.id} />;
188-
}}
189-
/>
284+
}}
285+
columns={columns}
286+
data={pageData}
287+
mantinePaperProps={{ style: { border: 'none' } }}
288+
enablePagination={false}
289+
enableColumnPinning
290+
initialState={{
291+
columnPinning: {
292+
left: ['rowNumber'],
293+
},
294+
}}
295+
enableStickyHeader
296+
defaultColumn={{ minSize: 100 }}
297+
layoutMode="grid"
298+
state={{
299+
columnPinning: {
300+
left: ['rowNumber'],
301+
},
302+
columnVisibility,
303+
columnOrder: orderedHeaders,
304+
}}
305+
mantineTableContainerProps={{
306+
style: {
307+
height: `calc(100vh - ${props.primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`,
308+
},
309+
}}
310+
renderColumnActionsMenuItems={({ column }) => {
311+
return <Column columnName={column.id} />;
312+
}}
313+
/>
314+
{contextMenu.visible && (
315+
<div
316+
ref={contextMenuRef}
317+
style={{
318+
top: contextMenu.y,
319+
left: contextMenu.x,
320+
}}
321+
className={tableStyles.contextMenuContainer}
322+
onClick={closeContextMenu}>
323+
<Menu opened={contextMenu.visible} onClose={closeContextMenu}>
324+
{(() => {
325+
const [start, end] = rowNumber.split(':').map(Number);
326+
const rowCount = end - start + 1;
327+
328+
if (rowCount === 1) {
329+
return (
330+
<Menu.Item
331+
onClick={() => {
332+
selectLog(contextMenu.row);
333+
closeContextMenu();
334+
}}>
335+
View JSON
336+
</Menu.Item>
337+
);
338+
}
339+
340+
return null;
341+
})()}
342+
<Menu.Item
343+
onClick={() => {
344+
copyJSON();
345+
closeContextMenu();
346+
}}>
347+
Copy JSON
348+
</Menu.Item>
349+
<Menu.Item
350+
onClick={() => {
351+
copyUrl();
352+
closeContextMenu();
353+
}}>
354+
Copy permalink
355+
</Menu.Item>
356+
</Menu>
357+
</div>
358+
)}
359+
</>
190360
);
191361
};
192362

0 commit comments

Comments
 (0)