From 7900103b028429dc9dac33c4ddf234b899c7478e Mon Sep 17 00:00:00 2001 From: Praveen K B <30530587+praveen5959@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:17:47 +0530 Subject: [PATCH] feat: add line share feature (#388) --- src/pages/Stream/Views/Explore/Footer.tsx | 3 +- .../Stream/Views/Explore/StaticLogTable.tsx | 304 ++++++++++++++---- src/pages/Stream/hooks/useParamsController.ts | 31 +- src/pages/Stream/providers/LogsProvider.tsx | 14 + src/pages/Stream/styles/Logs.module.css | 20 ++ 5 files changed, 300 insertions(+), 72 deletions(-) diff --git a/src/pages/Stream/Views/Explore/Footer.tsx b/src/pages/Stream/Views/Explore/Footer.tsx index 9d03413b..536793ff 100644 --- a/src/pages/Stream/Views/Explore/Footer.tsx +++ b/src/pages/Stream/Views/Explore/Footer.tsx @@ -10,7 +10,7 @@ import useMountedState from '@/hooks/useMountedState'; import classes from '../../styles/Footer.module.css'; import { LOGS_FOOTER_HEIGHT } from '@/constants/theme'; -const { setPageAndPageData, setCurrentPage, setCurrentOffset } = logsStoreReducers; +const { setPageAndPageData, setCurrentPage, setCurrentOffset, setRowNumber } = logsStoreReducers; const TotalCount = (props: { totalCount: number }) => { return ( @@ -93,6 +93,7 @@ const Footer = (props: { loaded: boolean; hasNoData: boolean; isFetchingCount: b const { totalPages, currentOffset, currentPage, perPage, totalCount } = tableOpts; const onPageChange = useCallback((page: number) => { + setLogsStore((store) => setRowNumber(store, '')); setLogsStore((store) => setPageAndPageData(store, page)); }, []); diff --git a/src/pages/Stream/Views/Explore/StaticLogTable.tsx b/src/pages/Stream/Views/Explore/StaticLogTable.tsx index e9b638da..15236d27 100644 --- a/src/pages/Stream/Views/Explore/StaticLogTable.tsx +++ b/src/pages/Stream/Views/Explore/StaticLogTable.tsx @@ -1,6 +1,6 @@ -import { Box } from '@mantine/core'; -import { useCallback, useMemo } from 'react'; -import type { ReactNode } from 'react'; +import { Box, Menu } from '@mantine/core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Dispatch, ReactNode, SetStateAction } from 'react'; import EmptyBox from '@/components/Empty'; import FilterPills from '../../components/FilterPills'; import tableStyles from '../../styles/Logs.module.css'; @@ -21,8 +21,11 @@ import { Log } from '@/@types/parseable/api/query'; import { CopyIcon } from './JSONView'; import { FieldTypeMap, useStreamStore } from '../../providers/StreamProvider'; import timeRangeUtils from '@/utils/timeRangeUtils'; +import { IconDotsVertical } from '@tabler/icons-react'; +import { copyTextToClipboard } from '@/utils'; +import { notifySuccess } from '@/utils/notification'; -const { setSelectedLog } = logsStoreReducers; +const { setSelectedLog, setRowNumber } = logsStoreReducers; const TableContainer = (props: { children: ReactNode }) => { return {props.children}; }; @@ -54,10 +57,23 @@ const getSanitizedValue = (value: CellType, isTimestamp: boolean) => { return String(value); }; -const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTypeMap: FieldTypeMap) => { +type ContextMenuState = { + visible: boolean; + x: number; + y: number; + row: Log | null; +}; + +const makeHeaderOpts = ( + headers: string[], + isSecureHTTPContext: boolean, + fieldTypeMap: FieldTypeMap, + rowNumber: string, + setContextMenu: Dispatch>, +) => { return _.reduce( headers, - (acc: { accessorKey: string; header: string; grow: boolean }[], header) => { + (acc: { accessorKey: string; header: string; grow: boolean }[], header, index) => { const isTimestamp = _.get(fieldTypeMap, header, null) === 'timestamp'; return [ @@ -76,8 +92,38 @@ const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTy }) .value(); const sanitizedValue = getSanitizedValue(value, isTimestamp); + let isFirstSelectedRow = false; + if (rowNumber) { + const [start] = rowNumber.split(':').map(Number); + isFirstSelectedRow = cell.row.index === start; + } + const isFirstColumn = index === 0; return ( -
+
+
{ + event.stopPropagation(); + setContextMenu({ + visible: true, + x: event.pageX, + y: event.pageY, + row: cell.row.original, + }); + }} + style={{ + display: isFirstSelectedRow && isFirstColumn ? 'flex' : '', + }}> + {isSecureHTTPContext + ? sanitizedValue && + : null} +
{sanitizedValue}
{isSecureHTTPContext ? sanitizedValue && : null} @@ -91,19 +137,31 @@ const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTy [], ); }; - const makeColumnVisiblityOpts = (columns: string[]) => { return _.reduce(columns, (acc, column) => ({ ...acc, [column]: false }), {}); }; const Table = (props: { primaryHeaderHeight: number }) => { - const [{ orderedHeaders, disabledColumns, pinnedColumns, pageData, wrapDisabledColumns }, setLogsStore] = - useLogsStore((store) => store.tableOpts); + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + row: null, + }); + + const contextMenuRef = useRef(null); + const [{ orderedHeaders, disabledColumns, pageData, wrapDisabledColumns, rowNumber }, setLogsStore] = useLogsStore( + (store) => store.tableOpts, + ); const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); const [fieldTypeMap] = useStreamStore((store) => store.fieldTypeMap); - const columns = useMemo(() => makeHeaderOpts(orderedHeaders, isSecureHTTPContext, fieldTypeMap), [orderedHeaders]); + const columns = useMemo( + () => makeHeaderOpts(orderedHeaders, isSecureHTTPContext, fieldTypeMap, rowNumber, setContextMenu), + [orderedHeaders, rowNumber], + ); const columnVisibility = useMemo(() => makeColumnVisiblityOpts(disabledColumns), [disabledColumns, orderedHeaders]); - const selectLog = useCallback((log: Log) => { + const selectLog = useCallback((log: Log | null) => { + if (!log) return; const selectedText = window.getSelection()?.toString(); if (selectedText !== undefined && selectedText?.length > 0) return; @@ -126,67 +184,179 @@ const Table = (props: { primaryHeaderHeight: number }) => { }, [wrapDisabledColumns], ); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (contextMenuRef.current && !contextMenuRef.current.contains(event.target as Node)) { + closeContextMenu(); + } + }; + + if (contextMenu.visible) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [contextMenu.visible]); + + const closeContextMenu = () => setContextMenu({ visible: false, x: 0, y: 0, row: null }); + + const copyUrl = useCallback(() => { + copyTextToClipboard(window.location.href); + notifySuccess({ message: 'Link Copied!' }); + }, [window.location.href]); + + const copyJSON = useCallback(() => { + const [start, end] = rowNumber.split(':').map(Number); + + const rowsToCopy = pageData.slice(start, end + 1); + + copyTextToClipboard(rowsToCopy); + notifySuccess({ message: 'JSON Copied!' }); + }, [rowNumber]); + + const handleRowClick = (index: number, event: React.MouseEvent) => { + let newRange = `${index}:${index}`; + + if ((event.ctrlKey || event.metaKey) && rowNumber) { + const [start, end] = rowNumber.split(':').map(Number); + const lastIndex = Math.max(start, end); + + const startIndex = Math.min(lastIndex, index); + const endIndex = Math.max(lastIndex, index); + newRange = `${startIndex}:${endIndex}`; + setLogsStore((store) => setRowNumber(store, newRange)); + } else { + if (rowNumber) { + const [start, end] = rowNumber.split(':').map(Number); + if (index >= start && index <= end) { + setLogsStore((store) => setRowNumber(store, '')); + return; + } + } + + setLogsStore((store) => setRowNumber(store, newRange)); + } + }; return ( - makeCellCustomStyles(id)} - mantineTableHeadRowProps={{ style: { border: 'none' } }} - mantineTableHeadCellProps={{ - style: { - fontWeight: 600, - fontSize: '0.65rem', - border: 'none', - padding: '0.5rem 1rem', - }, - }} - mantineTableBodyRowProps={({ row }) => { - return { - onClick: () => { - selectLog(row.original); + <> + makeCellCustomStyles(id)} + mantineTableHeadRowProps={{ style: { border: 'none' } }} + mantineTableHeadCellProps={{ + style: { + fontWeight: 600, + fontSize: '0.65rem', + border: 'none', + padding: '0.5rem 1rem', }, + }} + mantineTableBodyRowProps={({ row }) => { + return { + onClick: (event) => { + event.preventDefault(); + handleRowClick(row.index, event); + }, + style: { + border: 'none', + background: row.index % 2 === 0 ? '#f8f9fa' : 'white', + backgroundColor: + rowNumber && + (() => { + const [start, end] = rowNumber.split(':').map(Number); + return row.index >= start && row.index <= end; + })() + ? '#E8EDFE' + : '', + }, + }; + }} + mantineTableProps={{ highlightOnHover: false }} + mantineTableHeadProps={{ style: { border: 'none', - background: row.index % 2 === 0 ? '#f8f9fa' : 'white', }, - }; - }} - mantineTableHeadProps={{ - style: { - border: 'none', - }, - }} - columns={columns} - data={pageData} - mantinePaperProps={{ style: { border: 'none' } }} - enablePagination={false} - enableColumnPinning={true} - initialState={{ - columnPinning: { - left: pinnedColumns, - }, - }} - enableStickyHeader={true} - defaultColumn={{ minSize: 100 }} - layoutMode="grid" - state={{ - columnPinning: { - left: pinnedColumns, - }, - columnVisibility, - columnOrder: orderedHeaders, - }} - mantineTableContainerProps={{ - style: { - height: `calc(100vh - ${props.primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`, - }, - }} - renderColumnActionsMenuItems={({ column }) => { - return ; - }} - /> + }} + columns={columns} + data={pageData} + mantinePaperProps={{ style: { border: 'none' } }} + enablePagination={false} + enableColumnPinning + initialState={{ + columnPinning: { + left: ['rowNumber'], + }, + }} + enableStickyHeader + defaultColumn={{ minSize: 100 }} + layoutMode="grid" + state={{ + columnPinning: { + left: ['rowNumber'], + }, + columnVisibility, + columnOrder: orderedHeaders, + }} + mantineTableContainerProps={{ + style: { + height: `calc(100vh - ${props.primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`, + }, + }} + renderColumnActionsMenuItems={({ column }) => { + return ; + }} + /> + {contextMenu.visible && ( +
+ + {(() => { + const [start, end] = rowNumber.split(':').map(Number); + const rowCount = end - start + 1; + + if (rowCount === 1) { + return ( + { + selectLog(contextMenu.row); + closeContextMenu(); + }}> + View JSON + + ); + } + + return null; + })()} + { + copyJSON(); + closeContextMenu(); + }}> + Copy JSON + + { + copyUrl(); + closeContextMenu(); + }}> + Copy permalink + + +
+ )} + ); }; diff --git a/src/pages/Stream/hooks/useParamsController.ts b/src/pages/Stream/hooks/useParamsController.ts index 0af65474..c048edbd 100644 --- a/src/pages/Stream/hooks/useParamsController.ts +++ b/src/pages/Stream/hooks/useParamsController.ts @@ -12,11 +12,11 @@ import { generateQueryBuilderASTFromSQL } from '../utils'; import { appStoreReducers, TimeRange, useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; const { getRelativeStartAndEndDate, formatDateWithTimezone, getLocalTimezone } = timeRangeUtils; -const { onToggleView, setPerPage, setCustQuerySearchState } = logsStoreReducers; +const { onToggleView, setPerPage, setCustQuerySearchState, setRowNumber } = logsStoreReducers; const { setTimeRange, syncTimeRange } = appStoreReducers; const { applySavedFilters } = filterStoreReducers; const timeRangeFormat = 'DD-MMM-YYYY_HH-mmz'; -const keys = ['view', 'rows', 'interval', 'from', 'to', 'query', 'filterType']; +const keys = ['view', 'rows', 'interval', 'from', 'to', 'query', 'filterType', 'rowNumber']; const dateToParamString = (date: Date) => { return formatDateWithTimezone(date, timeRangeFormat); @@ -52,6 +52,14 @@ const deriveTimeRangeParams = (timerange: TimeRange): { interval: string } | { f } }; +const deriveRowNumber = (rowNumber: string) => { + if (rowNumber.length > 0) { + return { + rowNumber, + }; + } +}; + const storeToParamsObj = (opts: { timeRange: TimeRange; view: string; @@ -60,10 +68,12 @@ const storeToParamsObj = (opts: { rows: string; query: string; filterType: string; + rowNumber: string; }): Record => { - const { timeRange, offset, page, view, rows, query, filterType } = opts; + const { timeRange, offset, page, view, rows, query, filterType, rowNumber } = opts; const params: Record = { ...deriveTimeRangeParams(timeRange), + ...deriveRowNumber(rowNumber), view, offset, rows, @@ -94,10 +104,18 @@ const useParamsController = () => { const [, setLogsStore] = useLogsStore(() => null); const [, setFilterStore] = useFilterStore((store) => store); - const { currentOffset, currentPage, perPage } = tableOpts; + const { currentOffset, currentPage, perPage, rowNumber } = tableOpts; const [searchParams, setSearchParams] = useSearchParams(); + const syncRowNumber = useCallback((storeAsParams: Record, presentParams: Record) => { + if (_.has(presentParams, 'rowNumber')) { + if (storeAsParams.rowNumber !== presentParams.rowNumber) { + setLogsStore((store) => setRowNumber(store, presentParams.rowNumber)); + } + } + }, []); + useEffect(() => { const storeAsParams = storeToParamsObj({ timeRange, @@ -107,6 +125,7 @@ const useParamsController = () => { rows: `${perPage}`, query: custQuerySearchState.custSearchQuery, filterType: custQuerySearchState.viewMode, + rowNumber, }); const presentParams = paramsStringToParamsObj(searchParams); if (['table', 'json'].includes(presentParams.view) && presentParams.view !== storeAsParams.view) { @@ -125,6 +144,7 @@ const useParamsController = () => { ); } syncTimeRangeToStore(storeAsParams, presentParams); + syncRowNumber(storeAsParams, presentParams); setStoreSynced(true); }, []); @@ -138,6 +158,7 @@ const useParamsController = () => { rows: `${perPage}`, query: custQuerySearchState.custSearchQuery, filterType: custQuerySearchState.viewMode, + rowNumber, }); const presentParams = paramsStringToParamsObj(searchParams); if (_.isEqual(storeAsParams, presentParams)) return; @@ -156,6 +177,7 @@ const useParamsController = () => { rows: `${perPage}`, query: custQuerySearchState.custSearchQuery, filterType: custQuerySearchState.viewMode, + rowNumber, }); const presentParams = paramsStringToParamsObj(searchParams); @@ -178,6 +200,7 @@ const useParamsController = () => { setLogsStore((store) => setCustQuerySearchState(store, presentParams.query, presentParams.filterType)); } syncTimeRangeToStore(storeAsParams, presentParams); + syncRowNumber(storeAsParams, presentParams); }, [searchParams]); const syncTimeRangeToStore = useCallback( diff --git a/src/pages/Stream/providers/LogsProvider.tsx b/src/pages/Stream/providers/LogsProvider.tsx index 443c36b2..98c1da9b 100644 --- a/src/pages/Stream/providers/LogsProvider.tsx +++ b/src/pages/Stream/providers/LogsProvider.tsx @@ -187,6 +187,7 @@ type LogsStore = { instantSearchValue: string; configViewType: 'schema' | 'columns'; enableWordWrap: boolean; + rowNumber: string; }; data: LogQueryData; @@ -251,6 +252,7 @@ type LogsStoreReducers = { setDisabledColumns: (store: LogsStore, columns: string[]) => ReducerOutput; setOrderedHeaders: (store: LogsStore, columns: string[]) => ReducerOutput; toggleWordWrap: (store: LogsStore) => ReducerOutput; + setRowNumber: (store: LogsStore, rowNumber: string) => ReducerOutput; }; const defaultSortKey = 'p_timestamp'; @@ -290,6 +292,7 @@ const initialState: LogsStore = { instantSearchValue: '', configViewType: 'columns', enableWordWrap: true, + rowNumber: '', }, // data @@ -461,6 +464,16 @@ const togglePinnedColumns = (store: LogsStore, columnName: string) => { }; }; +const setRowNumber = (store: LogsStore, rowNumber: string) => { + const { tableOpts } = store; + return { + tableOpts: { + ...tableOpts, + rowNumber, + }, + }; +}; + const filterAndSortData = ( opts: { sortOrder: 'asc' | 'desc'; sortKey: string; filters: Record }, data: Log[], @@ -861,6 +874,7 @@ const logsStoreReducers: LogsStoreReducers = { toggleDeleteModal, toggleDisabledColumns, togglePinnedColumns, + setRowNumber, setLogData, setStreamSchema, setPerPage, diff --git a/src/pages/Stream/styles/Logs.module.css b/src/pages/Stream/styles/Logs.module.css index cd2308d8..384b27c4 100644 --- a/src/pages/Stream/styles/Logs.module.css +++ b/src/pages/Stream/styles/Logs.module.css @@ -272,5 +272,25 @@ right: 0; display: none; } + .actionIconContainer { + position: absolute; + top: 25%; + left: 3px; + cursor: pointer; + padding: 1px 0px; + border-radius: 4px; + background-color: white; + border: 0.8px solid #545beb; + display: none; + } } } + +.contextMenuContainer { + position: fixed; + z-index: 1000; + background-color: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +}