diff --git a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx index 8d04a70239..9c77d21704 100644 --- a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx +++ b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx @@ -13,7 +13,7 @@ export interface Props { items: any[] scanned?: number totalItemsCount?: number - nextCursor: string + nextCursor?: string scanMoreStyle?: { [key: string]: string | number; } diff --git a/redisinsight/ui/src/components/virtual-grid/interfaces.ts b/redisinsight/ui/src/components/virtual-grid/interfaces.ts index 6a330ee2c5..4603c66290 100644 --- a/redisinsight/ui/src/components/virtual-grid/interfaces.ts +++ b/redisinsight/ui/src/components/virtual-grid/interfaces.ts @@ -4,6 +4,7 @@ import { TableCellAlignment, TableCellTextAlignment, } from 'uiSrc/constants' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' export interface IColumnSearchState { initialSearchValue?: string @@ -18,7 +19,7 @@ export interface IColumnSearchState { export interface ITableColumn { id: string label: string | ReactNode - minWidth: number + minWidth?: number maxWidth?: number isSortable?: boolean isSearchable?: boolean diff --git a/redisinsight/ui/src/components/virtual-table/VirtualTable.spec.tsx b/redisinsight/ui/src/components/virtual-table/VirtualTable.spec.tsx index 2003407485..db6794d88b 100644 --- a/redisinsight/ui/src/components/virtual-table/VirtualTable.spec.tsx +++ b/redisinsight/ui/src/components/virtual-table/VirtualTable.spec.tsx @@ -203,4 +203,26 @@ describe('VirtualTable', () => { expect(onLoadMoreItems).toBeCalledWith(argMock) }) }) + + it('should show resize trigger for resizable column', () => { + const updatedColumns = [ + { + ...columns[0], + isResizable: true, + }, + ] + + render( + + ) + + expect(screen.getByTestId('resize-trigger-name')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx b/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx index 26fca9b908..800e26665f 100644 --- a/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx +++ b/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx @@ -1,32 +1,31 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { EuiIcon, EuiProgress, EuiResizeObserver, EuiText, } from '@elastic/eui' import cx from 'classnames' -import { InfiniteLoader, - Table, - Column, - IndexRange, +import { findIndex, isNumber, sumBy, xor } from 'lodash' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { CellMeasurer, - TableCellProps, CellMeasurerCache, + Column, + IndexRange, + InfiniteLoader, RowMouseEventHandlerParams, + Table, + TableCellProps, } from 'react-virtualized' -import { findIndex, isNumber, xor } from 'lodash' -import { - EuiText, - EuiProgress, - EuiResizeObserver, - EuiIcon, -} from '@elastic/eui' - -import { isEqualBuffers, Maybe, Nullable } from 'uiSrc/utils' +import TableColumnSearchTrigger from 'uiSrc/components/table-column-search-trigger/TableColumnSearchTrigger' +import TableColumnSearch from 'uiSrc/components/table-column-search/TableColumnSearch' import { SortOrder } from 'uiSrc/constants' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' -import TableColumnSearch from 'uiSrc/components/table-column-search/TableColumnSearch' -import TableColumnSearchTrigger from 'uiSrc/components/table-column-search-trigger/TableColumnSearchTrigger' -import { IColumnSearchState, IProps, IResizeEvent, ITableColumn } from './interfaces' + +import { isEqualBuffers, Maybe, Nullable } from 'uiSrc/utils' +import { ColumnWidthSizes, IColumnSearchState, IProps, IResizeEvent, ITableColumn, ResizableState } from './interfaces' import KeysSummary from '../keys-summary' import styles from './styles.module.scss' +// this is needed to align content when scrollbar appears +const TABLE_OUTSIDE_WIDTH = 24 + const VirtualTable = (props: IProps) => { const { selectable = false, @@ -64,17 +63,25 @@ const VirtualTable = (props: IProps) => { fixedWidth: true, minHeight: rowHeight, }), + onColResizeEnd } = props let selectTimer: number = 0 const selectTimerDelay = 300 let preventSelect = false const scrollTopRef = useRef(0) + const resizeColRef = useRef({ column: null, active: false, x: 0 }) + const [selectedRowIndex, setSelectedRowIndex] = useState>(null) const [search, setSearch] = useState([]) const [width, setWidth] = useState(100) const [height, setHeight] = useState(100) const [forceScrollTop, setForceScrollTop] = useState>(scrollTopProp) + const [columnWidthSizes, setColumnWidthSizes] = useState>(null) + const [isColResizing, setIsColResizing] = useState(false) + const [, forceUpdate] = useState({}) + + const [minWidthAllCols, setMinWidthAllCols] = useState(0) useEffect(() => { const searchableFields: ITableColumn[] = columns.filter( @@ -102,6 +109,10 @@ const VirtualTable = (props: IProps) => { } }, []) + useEffect(() => { + setMinWidthAllCols(sumBy(columns, ((col) => col?.minWidth || 0))) + }, [columns]) + useEffect(() => { if (forceScrollTop !== undefined) { setForceScrollTop(undefined) @@ -118,6 +129,34 @@ const VirtualTable = (props: IProps) => { cellCache?.clearAll() }, [totalItemsCount]) + const clearSelectTimeout = (timer: number = 0) => { + clearTimeout(timer || selectTimer) + preventSelect = true + } + + const clearCache = () => setTimeout(() => { + cellCache.clearAll() + forceUpdate({}) + }, 0) + + const getWidthOfColumn = (colId: string, colWidth: number, width: number, isRelative = false) => { + let newColWidth = isRelative ? (colWidth / 100) * width : colWidth + + const currentColumn = columns.find((col) => col.id === colId) + const maxWidthFromTable = width - (minWidthAllCols - (currentColumn?.minWidth || 0)) - TABLE_OUTSIDE_WIDTH + const maxWidth = currentColumn?.maxWidth || maxWidthFromTable + const minWidth = (currentColumn?.minWidth || 60) + + if (newColWidth > maxWidth) newColWidth = maxWidth + if (newColWidth < minWidth) newColWidth = minWidth + + const newAbsWidth = Math.floor(newColWidth) + return { + abs: newAbsWidth, + relative: (newAbsWidth / width) * 100 + } + } + const onRowSelect = (data: RowMouseEventHandlerParams) => { const isRowSelectable = checkIfRowSelectable(data.rowData) @@ -132,6 +171,9 @@ const VirtualTable = (props: IProps) => { if (!preventSelect && !textSelected) { setExpandedRows(xor(expandedRows, [data.index])) onRowToggleViewClick?.(expandedRows.indexOf(data.index) === -1, data.index) + + clearCache() + setTimeout(() => { clearCache() }, 0) } preventSelect = false }, selectTimerDelay, cellCache) @@ -144,25 +186,96 @@ const VirtualTable = (props: IProps) => { cellCache.clearAll() } } - const clearSelectTimeout = (timer: number = 0) => { - clearTimeout(timer || selectTimer) - preventSelect = true - } - const onScroll = useCallback( - ({ scrollTop }) => { - scrollTopRef.current = scrollTop - }, - [scrollTopRef], - ) + const onScroll = useCallback(({ scrollTop }: { scrollTop: number }) => { + scrollTopRef.current = scrollTop + }, [scrollTopRef]) const onResize = ({ height, width }: IResizeEvent): void => { setHeight(height) - setWidth(width) + setWidth(Math.floor(width)) onChangeWidth?.(width) + + if (!columnWidthSizes) { + // init width sizes + setColumnWidthSizes( + columns + .filter((col) => col.isResizable) + .reduce((prev, next) => { + const propAbsWidth = next.absoluteWidth && isNumber(next.absoluteWidth) ? next.absoluteWidth : 0 + return { + ...prev, + [next.id]: next.relativeWidth + ? getWidthOfColumn(next.id, next.relativeWidth, width, true) + : getWidthOfColumn(next.id, propAbsWidth, width) + } + }, {}) + ) + } + + if (columnWidthSizes) { + setColumnWidthSizes((colWidthSizesPrev) => { + const newSizes: ColumnWidthSizes = {} + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (const col in colWidthSizesPrev) { + newSizes[col] = getWidthOfColumn(col, colWidthSizesPrev[col].relative, width, true) + } + return newSizes + }) + } + cellCache?.clearAll() } + const onDragColumn = (e: React.MouseEvent) => { + const { column, x, active } = resizeColRef.current + if (active && column) { + const diffX = x - e.clientX + setColumnWidthSizes((prev) => { + if (!prev) return null + + resizeColRef.current.x = e.clientX + return ({ + ...prev, + [column]: getWidthOfColumn(column, prev[column].abs - diffX, width) + }) + }) + + cellCache?.clearAll() + } + } + + const onDragColumnStart = (e: React.MouseEvent, column: ITableColumn) => { + resizeColRef.current = { + column: column.id, + active: true, + x: e.clientX + } + setIsColResizing(true) + } + + const onDragColumnEnd = () => { + if (resizeColRef.current.active) { + resizeColRef.current = { + active: false, + column: null, + x: 0 + } + setIsColResizing(false) + cellCache?.clearAll() + + if (columnWidthSizes) { + onColResizeEnd?.( + Object.keys(columnWidthSizes) + .reduce((prev, next) => ({ + ...prev, + [next]: columnWidthSizes[next].relative + }), {}) + ) + } + } + } + const checkIfRowSelectable = (rowData: any) => !!rowData const cellRenderer = ({ cellData, columnIndex, rowData, rowIndex, parent, dataKey }: TableCellProps) => { @@ -221,7 +334,10 @@ const VirtualTable = (props: IProps) => { const isColumnSorted = sortedColumn && sortedColumn.column === column.id return ( -
+
{column.isSortable && !searching && (
)} + {column.isResizable && ( +
onDragColumnStart(e, column)} + data-testid={`resize-trigger-${column.id}`} + role="presentation" + /> + )}
) } @@ -363,8 +487,12 @@ const VirtualTable = (props: IProps) => { {(resizeRef) => (
{loading && !hideProgress && ( @@ -381,7 +509,7 @@ const VirtualTable = (props: IProps) => { minimumBatchSize={SCAN_COUNT_DEFAULT} threshold={threshold} loadMoreRows={loadMoreRows} - rowCount={totalItemsCount} + rowCount={totalItemsCount || undefined} > {({ onRowsRendered, registerChild }) => ( { maxWidth={column.maxWidth} label={column.label} dataKey={column.id} - width={ - column.absoluteWidth || column.relativeWidth - ? column.relativeWidth ?? 0 - : 20 - } + width={columnWidthSizes?.[column.id]?.abs || ( + column.absoluteWidth || column.relativeWidth ? column.relativeWidth ?? 0 : 20 + )} flexGrow={!column.absoluteWidth && !column.relativeWidth ? 1 : 0} headerRenderer={(headerProps) => headerRenderer({ @@ -448,7 +574,7 @@ const VirtualTable = (props: IProps) => {
void onRowsRendered?: (info: IndexRange & OverscanIndexRange) => void + onColResizeEnd?: (cols: RelativeWidthSizes) => void } export interface ISortedColumn { column: string order: SortOrder } + +export interface RelativeWidthSizes { + [key: string]: number +} + +export interface AbsoluteWidthSizes { + [key: string]: { + abs: number + } +} + +export type ColumnWidthSizes = AbsoluteWidthSizes & { + [key: string]: { + relative: number + } +} + +export interface ResizableState { + column: Nullable + active: boolean + x: number +} diff --git a/redisinsight/ui/src/components/virtual-table/styles.module.scss b/redisinsight/ui/src/components/virtual-table/styles.module.scss index 80d7dd49b4..17a0e80347 100644 --- a/redisinsight/ui/src/components/virtual-table/styles.module.scss +++ b/redisinsight/ui/src/components/virtual-table/styles.module.scss @@ -26,6 +26,11 @@ $footerHeight: 38px; height: 100%; width: 100%; padding-bottom: $footerHeight; + + &.isResizing { + user-select: none; + cursor: col-resize; + } } :global(.keys-tree__count) { @@ -240,6 +245,29 @@ $footerHeight: 38px; animation: dots 1s steps(5, end) infinite; } +.resizeTrigger { + position: absolute; + height: 100%; + right: -4px; + width: 7px; + + cursor: col-resize; + z-index: 2; + + &:before { + content: ''; + display: block; + width: 7px; + height: 8px; + border-left: 1px solid var(--tableLightestBorderColor); + border-right: 1px solid var(--tableLightestBorderColor); + + position: absolute; + top: 50%; + transform: translateY(-50%); + } +} + @keyframes dots { 0%, 20% { diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index e0c696b677..060adb42c9 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -19,7 +19,8 @@ enum BrowserStorageItem { RunQueryMode = 'RunQueryMode', wbCleanUp = 'wbCleanUp', viewFormat = 'viewFormat', - wbGroupMode = 'wbGroupMode' + wbGroupMode = 'wbGroupMode', + keyDetailSizes = 'keyDetailSizes' } export default BrowserStorageItem diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index e9a726a27a..d1155d6c1d 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -187,11 +187,9 @@ const BrowserPage = () => { @@ -222,15 +220,13 @@ const BrowserPage = () => { arePanelsCollapsed={arePanelsCollapsed} setSelectedKey={setSelectedKey} selectedKey={selectedKey} - panelsState={{ - isAddKeyPanelOpen, - isCreateIndexPanelOpen, - isBulkActionsPanelOpen, - handleAddKeyPanel, - handleBulkActionsPanel, - handleCreateIndexPanel, - closeRightPanels - }} + isAddKeyPanelOpen={isAddKeyPanelOpen} + isCreateIndexPanelOpen={isCreateIndexPanelOpen} + isBulkActionsPanelOpen={isBulkActionsPanelOpen} + handleAddKeyPanel={handleAddKeyPanel} + handleBulkActionsPanel={handleBulkActionsPanel} + handleCreateIndexPanel={handleCreateIndexPanel} + closeRightPanels={closeRightPanels} /> diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx index 73d87fee15..a725a38cc4 100644 --- a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx @@ -28,24 +28,18 @@ import styles from './styles.module.scss' export interface Props { arePanelsCollapsed: boolean selectKey: ({ rowData }: { rowData: any }) => void - panelsState: { - handleAddKeyPanel: (value: boolean) => void - handleBulkActionsPanel: (value: boolean) => void - handleCreateIndexPanel: (value: boolean) => void - } + handleAddKeyPanel: (value: boolean) => void + handleBulkActionsPanel: (value: boolean) => void + handleCreateIndexPanel: (value: boolean) => void } const BrowserLeftPanel = (props: Props) => { const { selectKey, - panelsState, - } = props - - const { handleAddKeyPanel, handleBulkActionsPanel, handleCreateIndexPanel, - } = panelsState + } = props const { instanceId } = useParams<{ instanceId: string }>() const patternKeysState = useSelector(keysDataSelector) diff --git a/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx index 2cfa20c678..8d1f9be261 100644 --- a/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx +++ b/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx @@ -23,15 +23,13 @@ export interface Props { selectedKey: Nullable setSelectedKey: (keyName: Nullable) => void arePanelsCollapsed: boolean - panelsState: { - isAddKeyPanelOpen: boolean - handleAddKeyPanel: (value: boolean) => void - isBulkActionsPanelOpen: boolean - handleBulkActionsPanel: (value: boolean) => void - isCreateIndexPanelOpen: boolean - handleCreateIndexPanel?: (value: boolean) => void - closeRightPanels: () => void - } + isAddKeyPanelOpen: boolean + handleAddKeyPanel: (value: boolean) => void + isBulkActionsPanelOpen: boolean + handleBulkActionsPanel: (value: boolean) => void + isCreateIndexPanelOpen: boolean + handleCreateIndexPanel?: (value: boolean) => void + closeRightPanels: () => void } const BrowserRightPanel = (props: Props) => { @@ -39,17 +37,13 @@ const BrowserRightPanel = (props: Props) => { selectedKey, arePanelsCollapsed, setSelectedKey, - panelsState - } = props - - const { isAddKeyPanelOpen, handleAddKeyPanel, isBulkActionsPanelOpen, handleBulkActionsPanel, isCreateIndexPanelOpen, closeRightPanels - } = panelsState + } = props const { isBrowserFullScreen, viewType } = useSelector(keysSelector) const { type, length } = useSelector(selectedKeyDataSelector) ?? { type: '', length: 0 } diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx index 0e00dd8252..bca2fe9caa 100644 --- a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx @@ -1,11 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' +import { RedisResponseBufferType } from 'uiSrc/slices/interfaces' import { bufferToString } from 'uiSrc/utils' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import HashDetails, { Props } from './HashDetails' const mockedProps = mock() -const fields = [ +const fields: Array<{ field: RedisResponseBufferType, value: RedisResponseBufferType }> = [ { field: { type: 'Buffer', data: [49] }, value: { type: 'Buffer', data: [49, 65] } }, { field: { type: 'Buffer', data: [49, 50, 51] }, value: { type: 'Buffer', data: [49, 11] } }, { field: { type: 'Buffer', data: [50] }, value: { type: 'Buffer', data: [49, 234, 453] } }, @@ -64,4 +65,9 @@ describe('HashDetails', () => { fireEvent.click(screen.getAllByTestId(/edit-hash-button/)[0]) expect(screen.getByTestId('hash-value-editor')).toBeInTheDocument() }) + + it('should render resize trigger for field column', () => { + render() + expect(screen.getByTestId('resize-trigger-field')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx index 3f2294116b..b50bd15008 100644 --- a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx @@ -4,58 +4,56 @@ import React, { ChangeEvent, Ref, useCallback, useEffect, useRef, useState } fro import { useDispatch, useSelector } from 'react-redux' import { CellMeasurerCache } from 'react-virtualized' import AutoSizer from 'react-virtualized-auto-sizer' +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { getColumnWidth } from 'uiSrc/components/virtual-grid' +import { StopPropagation } from 'uiSrc/components/virtual-table' +import { + IColumnSearchState, + ITableColumn, + RelativeWidthSizes, +} from 'uiSrc/components/virtual-table/interfaces' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { + KeyTypes, + OVER_RENDER_BUFFER_COUNT, + TableCellAlignment, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_INVALID_VALUE, + TEXT_UNPRINTABLE_CHARACTERS +} from 'uiSrc/constants' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import HelpTexts from 'uiSrc/constants/help-texts' +import { NoResultsFoundText } from 'uiSrc/constants/texts' +import { appContextBrowserKeyDetails, updateKeyDetailsSizes } from 'uiSrc/slices/app/context' import { - hashSelector, - hashDataSelector, deleteHashFields, fetchHashFields, fetchMoreHashFields, - updateHashValueStateSelector, + hashDataSelector, + hashSelector, updateHashFieldsAction, + updateHashValueStateSelector, } from 'uiSrc/slices/browser/hash' +import { keysSelector, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { getBasedOnViewTypeEvent, getMatchType, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { - formatLongName, + bufferToSerializedFormat, + bufferToString, createDeleteFieldHeader, createDeleteFieldMessage, - Nullable, + formatLongName, formattingBuffer, - bufferToString, - bufferToSerializedFormat, - stringToSerializedBufferFormat, - isNonUnicodeFormatter, + isEqualBuffers, isFormatEditable, - isEqualBuffers + isNonUnicodeFormatter, + Nullable, + stringToSerializedBufferFormat } from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' -import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' -import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' -import { - IColumnSearchState, - ITableColumn, -} from 'uiSrc/components/virtual-table/interfaces' -import { NoResultsFoundText } from 'uiSrc/constants/texts' -import { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' -import HelpTexts from 'uiSrc/constants/help-texts' -import { - KeyTypes, - OVER_RENDER_BUFFER_COUNT, - TableCellAlignment, - TEXT_INVALID_VALUE, - TEXT_DISABLED_FORMATTER_EDITING, - TEXT_UNPRINTABLE_CHARACTERS -} from 'uiSrc/constants' -import { getColumnWidth } from 'uiSrc/components/virtual-grid' -import { StopPropagation } from 'uiSrc/components/virtual-table' import { stringToBuffer } from 'uiSrc/utils/formatters/bufferFormatters' -import { - GetHashFieldsResponse, - AddFieldsToHashDto, - HashFieldDto, -} from 'apiSrc/modules/browser/dto/hash.dto' +import { AddFieldsToHashDto, GetHashFieldsResponse, HashFieldDto, } from 'apiSrc/modules/browser/dto/hash.dto' import PopoverDelete from '../popover-delete/PopoverDelete' import styles from './styles.module.scss' @@ -91,6 +89,7 @@ const HashDetails = (props: Props) => { const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) const { name: key, length } = useSelector(selectedKeyDataSelector) ?? { name: '' } const { loading: updateLoading } = useSelector(updateHashValueStateSelector) + const { [KeyTypes.Hash]: hashSizes } = useSelector(appContextBrowserKeyDetails) const [match, setMatch] = useState>(matchAllValue) const [deleting, setDeleting] = useState('') @@ -271,11 +270,21 @@ const HashDetails = (props: Props) => { } } + const onColResizeEnd = (sizes: RelativeWidthSizes) => { + dispatch(updateKeyDetailsSizes({ + type: KeyTypes.Hash, + sizes + })) + } + const columns: ITableColumn[] = [ { id: 'field', label: 'Field', isSearchable: true, + isResizable: true, + minWidth: 120, + relativeWidth: hashSizes?.field || 40, prependSearchName: 'Field:', initialSearchValue: '', truncateText: true, @@ -311,6 +320,7 @@ const HashDetails = (props: Props) => { { id: 'value', label: 'Value', + minWidth: 120, truncateText: true, alignment: TableCellAlignment.Left, render: function Value( @@ -476,7 +486,7 @@ const HashDetails = (props: Props) => { { onRowToggleViewClick={handleRowToggleViewClick} expandedRows={expandedRows} setExpandedRows={setExpandedRows} + onColResizeEnd={onColResizeEnd} />
diff --git a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx index e6f4f20650..56f789276c 100644 --- a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx @@ -1,6 +1,9 @@ import React from 'react' +import { mock } from 'ts-mockito' import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import ListDetails from './ListDetails' +import ListDetails, { Props } from './ListDetails' + +const mockedProps = mock() const elements = [ { element: { type: 'Buffer', data: [49] }, index: 0 }, @@ -30,11 +33,11 @@ jest.mock('uiSrc/slices/browser/list', () => { describe('ListDetails', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('should render rows properly', () => { - const { container } = render() + const { container } = render() const rows = container.querySelectorAll( '.ReactVirtualized__Table__row[role="row"]' ) @@ -42,22 +45,27 @@ describe('ListDetails', () => { }) it('should render search input', () => { - render() + render() expect(screen.getByTestId('search')).toBeTruthy() }) it('should call search', () => { - render() + render() const searchInput = screen.getByTestId('search') fireEvent.change(searchInput, { target: { value: '111' } }) expect(searchInput).toHaveValue('111') }) it('should render editor after click edit button', async () => { - render() + render() await act(() => { fireEvent.click(screen.getAllByTestId(/edit-list-button/)[0]) }) expect(screen.getByTestId('element-value-editor')).toBeInTheDocument() }) + + it('should render resize trigger for index column', () => { + render() + expect(screen.getByTestId('resize-trigger-index')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx index 53c790b861..354b81a7d5 100644 --- a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx @@ -5,6 +5,7 @@ import cx from 'classnames' import { isNull } from 'lodash' import { CellMeasurerCache } from 'react-virtualized' import AutoSizer from 'react-virtualized-auto-sizer' +import { appContextBrowserKeyDetails, updateKeyDetailsSizes } from 'uiSrc/slices/app/context' import { listSelector, @@ -18,6 +19,7 @@ import { import { ITableColumn, IColumnSearchState, + RelativeWidthSizes, } from 'uiSrc/components/virtual-table/interfaces' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' @@ -69,7 +71,7 @@ const cellCache = new CellMeasurerCache({ interface IListElement extends SetListElementResponse {} -interface Props { +export interface Props { isFooterOpen: boolean } @@ -84,6 +86,7 @@ const ListDetails = (props: Props) => { const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType } = useSelector(keysSelector) const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + const { [KeyTypes.List]: listSizes } = useSelector(appContextBrowserKeyDetails) const [elements, setElements] = useState([]) const [width, setWidth] = useState(100) @@ -219,15 +222,22 @@ const ListDetails = (props: Props) => { } } + const onColResizeEnd = (sizes: RelativeWidthSizes) => { + dispatch(updateKeyDetailsSizes({ + type: KeyTypes.List, + sizes + })) + } + const columns: ITableColumn[] = [ { id: 'index', label: 'Index', - minWidth: 220, - maxWidth: 220, - absoluteWidth: 220, + minWidth: 120, + relativeWidth: listSizes?.index || 30, truncateText: true, isSearchable: true, + isResizable: true, prependSearchName: 'Index:', initialSearchValue: '', searchValidation: validateListIndex, @@ -257,6 +267,7 @@ const ListDetails = (props: Props) => { { id: 'element', label: 'Element', + minWidth: 150, truncateText: true, alignment: TableCellAlignment.Left, render: function Element( @@ -435,6 +446,7 @@ const ListDetails = (props: Props) => { onRowToggleViewClick={handleRowToggleViewClick} expandedRows={expandedRows} setExpandedRows={setExpandedRows} + onColResizeEnd={onColResizeEnd} /> ) diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx index 7d8e9af815..31a7c32d09 100644 --- a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx @@ -70,4 +70,9 @@ describe('ZSetDetails', () => { fireEvent.change(screen.getByTestId('inline-item-editor'), { target: { value: '123' } }) expect(screen.getByTestId('inline-item-editor')).toHaveValue('123') }) + + it('should render resize trigger for name column', () => { + render() + expect(screen.getByTestId('resize-trigger-name')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx index 62d87bf419..345d423704 100644 --- a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx @@ -4,6 +4,7 @@ import { toNumber, isNumber } from 'lodash' import cx from 'classnames' import { EuiButtonIcon, EuiProgress, EuiText, EuiToolTip } from '@elastic/eui' import { CellMeasurerCache } from 'react-virtualized' +import { appContextBrowserKeyDetails, updateKeyDetailsSizes } from 'uiSrc/slices/app/context' import { zsetSelector, @@ -36,7 +37,7 @@ import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchTy import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' -import { IColumnSearchState, ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { IColumnSearchState, ITableColumn, RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' import { StopPropagation } from 'uiSrc/components/virtual-table' import { getColumnWidth } from 'uiSrc/components/virtual-grid' import { stringToBuffer } from 'uiSrc/utils/formatters/bufferFormatters' @@ -73,6 +74,7 @@ const ZSetDetails = (props: Props) => { const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType } = useSelector(keysSelector) const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + const { [KeyTypes.ZSet]: ZSetSizes } = useSelector(appContextBrowserKeyDetails) const [match, setMatch] = useState('') const [deleting, setDeleting] = useState('') @@ -231,6 +233,13 @@ const ZSetDetails = (props: Props) => { cellCache.clearAll() } + const onColResizeEnd = (sizes: RelativeWidthSizes) => { + dispatch(updateKeyDetailsSizes({ + type: KeyTypes.ZSet, + sizes + })) + } + const columns:ITableColumn[] = [ { id: 'name', @@ -239,6 +248,9 @@ const ZSetDetails = (props: Props) => { prependSearchName: 'Member:', initialSearchValue: '', truncateText: true, + isResizable: true, + minWidth: 140, + relativeWidth: ZSetSizes?.name || 60, alignment: TableCellAlignment.Left, className: 'value-table-separate-border', headerClassName: 'value-table-separate-border', @@ -274,6 +286,7 @@ const ZSetDetails = (props: Props) => { { id: 'score', label: 'Score', + minWidth: 100, isSortable: true, truncateText: true, render: function Score(_name: string, { name: nameItem, score, editing }: IZsetMember, expanded?: boolean) { @@ -443,6 +456,7 @@ const ZSetDetails = (props: Props) => { onRowToggleViewClick={handleRowToggleViewClick} expandedRows={expandedRows} setExpandedRows={setExpandedRows} + onColResizeEnd={onColResizeEnd} /> diff --git a/redisinsight/ui/src/slices/app/context.ts b/redisinsight/ui/src/slices/app/context.ts index 819b11b19b..3bf65167ed 100644 --- a/redisinsight/ui/src/slices/app/context.ts +++ b/redisinsight/ui/src/slices/app/context.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' import { getTreeLeafField, Nullable } from 'uiSrc/utils' -import { BrowserStorageItem, DEFAULT_DELIMITER } from 'uiSrc/constants' +import { BrowserStorageItem, DEFAULT_DELIMITER, KeyTypes } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' import { RootState } from '../store' import { RedisResponseBuffer, StateAppContext } from '../interfaces' @@ -25,6 +26,11 @@ export const initialState: StateAppContext = { }, bulkActions: { opened: false, + }, + keyDetailsSizes: { + [KeyTypes.Hash]: localStorageService?.get(BrowserStorageItem.keyDetailSizes)?.hash ?? null, + [KeyTypes.List]: localStorageService?.get(BrowserStorageItem.keyDetailSizes)?.list ?? null, + [KeyTypes.ZSet]: localStorageService?.get(BrowserStorageItem.keyDetailSizes)?.zset ?? null, } }, workbench: { @@ -54,6 +60,10 @@ const appContextSlice = createSlice({ // don't need to reset instanceId setAppContextInitialState: (state) => ({ ...initialState, + browser: { + ...initialState.browser, + keyDetailsSizes: state.browser.keyDetailsSizes + }, contextInstanceId: state.contextInstanceId }), // set connected instance @@ -151,6 +161,14 @@ const appContextSlice = createSlice({ setLastAnalyticsPage: (state, { payload }: { payload: string }) => { state.analytics.lastViewedPage = payload }, + updateKeyDetailsSizes: ( + state, + { payload }: { payload: { type: KeyTypes, sizes: RelativeWidthSizes } } + ) => { + const { type, sizes } = payload + state.browser.keyDetailsSizes[type] = sizes + localStorageService?.set(BrowserStorageItem.keyDetailSizes, state.browser.keyDetailsSizes) + } }, }) @@ -179,6 +197,7 @@ export const { setPubSubFieldsContext, setBrowserBulkActionOpen, setLastAnalyticsPage, + updateKeyDetailsSizes } = appContextSlice.actions // Selectors @@ -188,6 +207,8 @@ export const appContextBrowser = (state: RootState) => state.app.context.browser export const appContextBrowserTree = (state: RootState) => state.app.context.browser.tree +export const appContextBrowserKeyDetails = (state: RootState) => + state.app.context.browser.keyDetailsSizes export const appContextWorkbench = (state: RootState) => state.app.context.workbench export const appContextSelectedKey = (state: RootState) => diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index 308a5fb70b..bf88afa8da 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -1,4 +1,5 @@ import { AxiosError } from 'axios' +import { RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' import { Nullable } from 'uiSrc/utils' import { ICommands } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' @@ -64,6 +65,9 @@ export interface StateAppContext { }, bulkActions: { opened: boolean + }, + keyDetailsSizes: { + [key: string]: Nullable } }, workbench: { diff --git a/redisinsight/ui/src/slices/tests/app/context.spec.ts b/redisinsight/ui/src/slices/tests/app/context.spec.ts index 29d241a020..040a05c701 100644 --- a/redisinsight/ui/src/slices/tests/app/context.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/context.spec.ts @@ -33,7 +33,7 @@ import reducer, { updateBrowserTreeSelectedLeaf, setBrowserTreeDelimiter, setBrowserIsNotRendered, - setBrowserRedisearchScrollPosition, + setBrowserRedisearchScrollPosition, updateKeyDetailsSizes, appContextBrowserKeyDetails, } from '../../app/context' jest.mock('uiSrc/services', () => ({ @@ -64,6 +64,7 @@ describe('slices', () => { ...initialState, contextInstanceId, browser: { + ...initialState.browser, keyList: { isDataLoaded: true, scrollTopPosition: 100, @@ -74,7 +75,7 @@ describe('slices', () => { }, bulkActions: { opened: true, - } + }, }, workbench: { script: '123123', @@ -703,4 +704,31 @@ describe('slices', () => { expect(appContextBrowserTree(rootState)).toEqual(state) }) }) + + describe('updateKeyDetailsSizes', () => { + it('should properly update sizes', () => { + // Arrange + const payload = { + type: KeyTypes.Hash, + sizes: { + field: 50 + } + } + + const state = { + ...initialState.browser.keyDetailsSizes, + [KeyTypes.Hash]: { ...payload.sizes } + } + + // Act + const nextState = reducer(initialState, updateKeyDetailsSizes(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { context: nextState }, + }) + + expect(appContextBrowserKeyDetails(rootState)).toEqual(state) + }) + }) }) diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index 4f87be5e4c..be3a5c09a6 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -21,7 +21,6 @@ export async function addNewStandaloneDatabaseApi(databaseParameters: AddNewData 'password': databaseParameters.databasePassword }) .set('Accept', 'application/json'); - await t .expect(response.status).eql(201, 'The creation of new standalone database request failed') .expect(await response.body.name).eql(databaseParameters.databaseName, `Database Name is not equal to ${databaseParameters.databaseName} in response`); diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index f98b721302..01ac2218e0 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -10,6 +10,7 @@ export class BrowserPage { cssSelectorKey = '[data-testid^=key-]'; cssFilteringLabel = '[data-testid=multi-search]'; cssJsonValue = '[data-tesid=value-as-json]'; + cssRowInVirtualizedTable = '[role=gridcell]'; cssVirtualTableRow = '[aria-label=row]'; cssKeyBadge = '[data-testid^=badge-]'; cssKeyTtl = '[data-testid^=ttl-]'; @@ -135,6 +136,7 @@ export class BrowserPage { createIndexBtn = Selector('[data-testid=create-index-btn]'); cancelIndexCreationBtn = Selector('[data-testid=create-index-cancel-btn]'); confirmIndexCreationBtn = Selector('[data-testid=create-index-btn]'); + resizeTrigger = Selector('[data-testid^=resize-trigger-]'); //TABS streamTabGroups = Selector('[data-testid=stream-tab-Groups]'); streamTabConsumers = Selector('[data-testid=stream-tab-Consumers]'); @@ -560,6 +562,16 @@ export class BrowserPage { await t.click(this.confirmDeleteKeyButton); } + /** + * Delete keys by their Names + * @param keyNames The names of the key array + */ + async deleteKeysByNames(keyNames: string[]): Promise { + for(const name of keyNames) { + await this.deleteKeyByName(name); + } + } + /** * Edit key name from details * @param keyName The name of the key diff --git a/tests/e2e/tests/regression/browser/resize-columns.e2e.ts b/tests/e2e/tests/regression/browser/resize-columns.e2e.ts new file mode 100644 index 0000000000..70302f8a3f --- /dev/null +++ b/tests/e2e/tests/regression/browser/resize-columns.e2e.ts @@ -0,0 +1,96 @@ +import { acceptLicenseTerms } from '../../../helpers/database'; +import { + MyRedisDatabasePage, + BrowserPage +} from '../../../pageObjects'; +import { rte } from '../../../helpers/constants'; +import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; +import { addNewStandaloneDatabasesApi, deleteStandaloneDatabasesApi } from '../../../helpers/api/api-database'; +import { Common } from '../../../helpers/common'; + +const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); +const common = new Common(); + +const keyName = common.generateWord(10); +const longFieldName = common.generateSentence(20); +const keys = [ + { type: 'Hash', + name: `${keyName}:1`, + offsetX: 100, + fieldWidthStart: 0, + fieldWidthEnd: 0 + }, + { + type: 'List', + name: `${keyName}:2`, + offsetX: 80, + fieldWidthStart: 0, + fieldWidthEnd: 0 + }, + { + type: 'Zset', + name: `${keyName}:3`, + offsetX: 50, + fieldWidthStart: 0, + fieldWidthEnd: 0 + } +]; +const keyNames: string[] = []; +keys.forEach(key => keyNames.push(key.name)); + +const databasesForAdding = [ + { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port, databaseName: 'testDB1' }, + { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port, databaseName: 'testDB2' } +]; + +fixture `Resize columns in Key details` + .meta({type: 'regression', rte: rte.standalone}) + .page(commonUrl) + .beforeEach(async() => { + // Add new databases using API + await acceptLicenseTerms(); + await addNewStandaloneDatabasesApi(databasesForAdding); + // Reload Page + await common.reloadPage(); + await myRedisDatabasePage.clickOnDBByName(databasesForAdding[0].databaseName); + await browserPage.addHashKey(keys[0].name, '2147476121', longFieldName, longFieldName); + await browserPage.addListKey(keys[1].name, '2147476121', 'element'); + await browserPage.addZSetKey(keys[2].name, '1', '2147476121', 'member'); + }) + .afterEach(async() => { + // Clear and delete database + await browserPage.deleteKeysByNames(keyNames); + await deleteStandaloneDatabasesApi(databasesForAdding); + }); +test('Resize of columns in Hash, List, Zset Key details', async t => { + const field = browserPage.keyDetailsTable.find(browserPage.cssRowInVirtualizedTable); + const tableHeaderResizeTrigger = browserPage.resizeTrigger; + + for(const key of keys) { + await browserPage.openKeyDetails(key.name); + // Remember initial column width + key.fieldWidthStart = await field.clientWidth; + await t.hover(tableHeaderResizeTrigger); + await t.drag(tableHeaderResizeTrigger, -key.offsetX, 0, { speed: 0.5 }); + // Remember last column width + key.fieldWidthEnd = await field.clientWidth; + // Verify that user can resize columns for Hash, List, Zset Keys + await t.expect(key.fieldWidthEnd).eql(key.fieldWidthStart - key.offsetX, `Field is not resized for ${key.type} key`); + } + + // Verify that resize saved when switching between pages + await t.click(myRedisDatabasePage.workbenchButton); + await t.click(myRedisDatabasePage.browserButton); + await browserPage.openKeyDetails(keys[0].name); + await t.expect(field.clientWidth).eql(keys[0].fieldWidthEnd, 'Resize context not saved for key when switching between pages'); + + // Verify that resize saved when switching between databases + await t.click(myRedisDatabasePage.myRedisDBButton); + await myRedisDatabasePage.clickOnDBByName(databasesForAdding[1].databaseName); + // Verify that resize saved for specific data type + for(const key of keys) { + await browserPage.openKeyDetails(key.name); + await t.expect(field.clientWidth).eql(key.fieldWidthEnd, `Resize context not saved for ${key.type} key when switching between databases`); + } +});