From 941e021bae0685bcdc1f1c7f4f12df2e6e7dfa5a Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Fri, 20 Oct 2023 10:19:43 +0200 Subject: [PATCH 01/96] #RI-4828 - Re-work the Tree view --- patches/react-vtree+3.0.0-beta.3.patch | 24 ++ .../src/assets/img/browser/treeViewSort.svg | 10 + .../components/keys-summary/KeysSummary.tsx | 11 +- .../keys-summary/styles.module.scss | 4 + .../virtual-tree/VirtualTree.spec.tsx | 25 -- .../components/virtual-tree/VirtualTree.tsx | 231 +++++++------ .../components/Node/Node.spec.tsx | 12 +- .../virtual-tree/components/Node/Node.tsx | 323 +++++++++++++++--- .../components/Node/styles.module.scss | 87 ++++- .../src/components/virtual-tree/interfaces.ts | 22 +- .../virtual-tree/styles.module.scss | 4 + redisinsight/ui/src/constants/browser.ts | 3 +- .../ui/src/helpers/constructKeysToTree.ts | 76 +++-- .../tests/constructKeysToTreeMockResult.ts | 203 +++++------ .../browser-left-panel/BrowserLeftPanel.tsx | 2 + .../browser-right-panel/BrowserRightPanel.tsx | 8 +- .../filter-key-type/FilterKeyType.tsx | 2 + .../key-details-header/KeyDetailsHeader.tsx | 4 +- .../browser/components/key-list/KeyList.tsx | 47 +-- .../components/key-tree/KeyTree.spec.tsx | 58 +--- .../browser/components/key-tree/KeyTree.tsx | 229 ++++++------- .../KeyTreeDelimiter.spec.tsx | 106 ------ .../KeyTreeDelimiter/KeyTreeDelimiter.tsx | 87 ----- .../key-tree/KeyTreeDelimiter/index.ts | 3 - .../KeyTreeDelimiter/styles.module.scss | 68 ---- .../KeyTreeSettings/KeyTreeSettings.spec.tsx | 154 +++++++++ .../KeyTreeSettings/KeyTreeSettings.tsx | 174 ++++++++++ .../key-tree/KeyTreeSettings/index.ts | 3 + .../KeyTreeSettings/styles.module.scss | 84 +++++ .../browser/components/key-tree/index.ts | 5 + .../components/key-tree/styles.module.scss | 62 +--- .../components/keys-header/KeysHeader.tsx | 1 + .../no-keys-found/styles.module.scss | 2 +- .../no-keys-message/NoKeysMessage.spec.tsx | 21 ++ .../no-keys-message/NoKeysMessage.tsx | 65 ++++ .../components/no-keys-message/index.ts | 3 + .../search-key-list/SearchKeyList.tsx | 3 + redisinsight/ui/src/slices/app/context.ts | 48 +-- redisinsight/ui/src/slices/browser/keys.ts | 33 ++ redisinsight/ui/src/slices/interfaces/app.ts | 13 +- .../ui/src/slices/tests/app/context.spec.ts | 196 +---------- redisinsight/ui/src/telemetry/events.ts | 1 + redisinsight/ui/src/utils/tests/tree.spec.ts | 23 +- redisinsight/ui/src/utils/tree.ts | 25 -- 44 files changed, 1395 insertions(+), 1170 deletions(-) create mode 100644 patches/react-vtree+3.0.0-beta.3.patch create mode 100644 redisinsight/ui/src/assets/img/browser/treeViewSort.svg delete mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.spec.tsx delete mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.tsx delete mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/index.ts delete mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/no-keys-message/index.ts diff --git a/patches/react-vtree+3.0.0-beta.3.patch b/patches/react-vtree+3.0.0-beta.3.patch new file mode 100644 index 0000000000..7629fc04cc --- /dev/null +++ b/patches/react-vtree+3.0.0-beta.3.patch @@ -0,0 +1,24 @@ +diff --git a/node_modules/react-vtree/dist/es/Tree.d.ts b/node_modules/react-vtree/dist/es/Tree.d.ts +index 5e7f57e..15c09d4 100644 +--- a/node_modules/react-vtree/dist/es/Tree.d.ts ++++ b/node_modules/react-vtree/dist/es/Tree.d.ts +@@ -24,6 +24,7 @@ export declare type NodePublicState = Readonly<{ + data: TData; + setOpen: (state: boolean) => Promise; + }> & { ++ index: number; + isOpen: boolean; + }; + export declare type NodeRecord> = Readonly<{ +diff --git a/node_modules/react-vtree/dist/es/Tree.js b/node_modules/react-vtree/dist/es/Tree.js +index 2b1c7c0..b22e873 100644 +--- a/node_modules/react-vtree/dist/es/Tree.js ++++ b/node_modules/react-vtree/dist/es/Tree.js +@@ -19,6 +19,7 @@ export var Row = function Row(_ref) { + return /*#__PURE__*/React.createElement(Node, Object.assign({ + isScrolling: isScrolling, + style: style, ++ index: index, + treeData: treeData + }, data)); + }; diff --git a/redisinsight/ui/src/assets/img/browser/treeViewSort.svg b/redisinsight/ui/src/assets/img/browser/treeViewSort.svg new file mode 100644 index 0000000000..97b88a16ed --- /dev/null +++ b/redisinsight/ui/src/assets/img/browser/treeViewSort.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx index aea5e73c2d..979c19b881 100644 --- a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx +++ b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx @@ -2,10 +2,14 @@ import React from 'react' import cx from 'classnames' import { isNull } from 'lodash' import { EuiText, EuiTextColor } from '@elastic/eui' +import { useSelector } from 'react-redux' import { numberWithSpaces, nullableNumberWithSpaces } from 'uiSrc/utils/numbers' -import ScanMore from '../scan-more' +import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { keysSelector } from 'uiSrc/slices/browser/keys' +import { KeyTreeSettings } from 'uiSrc/pages/browser/components/key-tree' +import ScanMore from '../scan-more' import styles from './styles.module.scss' export interface Props { @@ -41,6 +45,8 @@ const KeysSummary = (props: Props) => { && nextCursor !== '0' ? '~' : '' + const { viewType } = useSelector(keysSelector) + return ( <> {(!!totalItemsCount || isNull(totalItemsCount)) && ( @@ -88,6 +94,9 @@ const KeysSummary = (props: Props) => { )} + {viewType === KeyViewType.Tree && ( + + )} )} {loading && !totalItemsCount && !isNull(totalItemsCount) && ( diff --git a/redisinsight/ui/src/components/keys-summary/styles.module.scss b/redisinsight/ui/src/components/keys-summary/styles.module.scss index 198f838ff6..294a787183 100644 --- a/redisinsight/ui/src/components/keys-summary/styles.module.scss +++ b/redisinsight/ui/src/components/keys-summary/styles.module.scss @@ -1,3 +1,7 @@ +.content { + display: flex; +} + .loading { opacity: 0; } diff --git a/redisinsight/ui/src/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/components/virtual-tree/VirtualTree.spec.tsx index fcd74e9a79..b869123dd7 100644 --- a/redisinsight/ui/src/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/components/virtual-tree/VirtualTree.spec.tsx @@ -15,10 +15,6 @@ const mockedItems = [ }, ] -export const mockLeafKeys = { - test: { name: 'test', type: 'hash', ttl: -1, size: 9849176 } -} - export const mockVirtualTreeResult = [{ children: [{ children: [], @@ -41,7 +37,6 @@ export const mockVirtualTreeResult = [{ keyApproximate: 0.01, keyCount: 1, name: 'test', - keys: mockLeafKeys }] jest.mock('uiSrc/services', () => ({ @@ -79,24 +74,4 @@ describe('VirtualTree', () => { expect(queryByTestId('node-item_test')).toBeInTheDocument() }) - - it('should select first leaf "Keys" by default', async () => { - const mockConstructingTreeFn = jest.fn() - const mockOnStatusSelected = jest.fn() - const mockOnSelectLeaf = jest.fn() - - render( - - ) - - expect(mockOnSelectLeaf).toHaveBeenCalledWith(mockLeafKeys) - expect(mockOnStatusSelected).toHaveBeenCalledWith('test', mockLeafKeys) - }) }) diff --git a/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx index e9f2ddbab1..ce2dbcee55 100644 --- a/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx @@ -1,22 +1,24 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react' +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' -import { isArray, isEmpty } from 'lodash' +import { debounce, get, set } from 'lodash' import { TreeWalker, TreeWalkerValue, FixedSizeTree as Tree, } from 'react-vtree' -import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui' +import { EuiIcon, EuiLoadingSpinner, EuiProgress } from '@elastic/eui' import { useDispatch } from 'react-redux' -import { findTreeNode, getTreeLeafField, Maybe } from 'uiSrc/utils' +import { bufferToString, Maybe, Nullable } from 'uiSrc/utils' import { useDisposableWebworker } from 'uiSrc/services' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { ThemeContext } from 'uiSrc/contexts/themeContext' -import { DEFAULT_DELIMITER, Theme } from 'uiSrc/constants' +import { DEFAULT_DELIMITER, DEFAULT_TREE_SORTING, KeyTypes, ModulesKeyTypes, SortOrder, Theme } from 'uiSrc/constants' import KeyLightSVG from 'uiSrc/assets/img/sidebar/browser.svg' import KeyDarkSVG from 'uiSrc/assets/img/sidebar/browser_active.svg' -import { resetBrowserTree } from 'uiSrc/slices/app/context' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' +import { fetchKeysMetadataTree } from 'uiSrc/slices/browser/keys' +import { GetKeyInfoResponse } from 'apiSrc/modules/browser/dto' import { Node } from './components/Node' import { NodeMeta, TreeData, TreeNode } from './interfaces' @@ -28,21 +30,21 @@ export interface Props { delimiter?: string loadingIcon?: string loading: boolean - selectDefaultLeaf?: boolean - statusSelected: { - [key: string]: { - [key: string]: IKeyPropTypes - } - }, - statusOpen: { - [key: string]: boolean - } + deleting: boolean + sorting: Maybe + commonFilterType: Nullable + statusSelected: Nullable, + statusOpen: OpenedNodes webworkerFn: (...args: any) => any - onSelectLeaf?: (items: any[]) => void - disableSelectDefaultLeaf?: () => void onStatusOpen?: (name: string, value: boolean) => void - onStatusSelected?: (id: string, keys: any) => void + onStatusSelected?: (key: RedisString) => void setConstructingTree: (status: boolean) => void + onDeleteLeaf: (key: RedisResponseBuffer) => void + onDeleteClicked: (type: KeyTypes | ModulesKeyTypes) => void +} + +interface OpenedNodes { + [key: string]: boolean; } export const KEYS = 'keys' @@ -53,26 +55,34 @@ const VirtualTree = (props: Props) => { delimiter = DEFAULT_DELIMITER, loadingIcon = 'empty', statusOpen = {}, - statusSelected = {}, + statusSelected, loading, - selectDefaultLeaf, + deleting, + sorting = DEFAULT_TREE_SORTING, + commonFilterType, onStatusOpen, onStatusSelected, - onSelectLeaf, setConstructingTree, - disableSelectDefaultLeaf, - webworkerFn = () => {} + webworkerFn = () => {}, + onDeleteClicked, + onDeleteLeaf, } = props const { theme } = useContext(ThemeContext) - const [nodes, setNodes] = useState([]) + const [rerenderState, rerender] = useState({}) + const controller = useRef>(null) + const elements = useRef({}) + const nodes = useRef([]) + const { result, run: runWebworker } = useDisposableWebworker(webworkerFn) const dispatch = useDispatch() useEffect(() => - () => setNodes([]), - []) + () => { + nodes.current = [] + elements.current = [] + }, []) // receive result from the "runWebworker" useEffect(() => { @@ -80,73 +90,82 @@ const VirtualTree = (props: Props) => { return } - setNodes(result) + elements.current = [] + nodes.current = result + rerender({}) setConstructingTree?.(false) }, [result]) - // select "root" Keys after render a new tree (construct a tree) useEffect(() => { - if (nodes.length === 0 || !selectDefaultLeaf || loading) { + if (!items?.length) { + nodes.current = [] + elements.current = [] + rerender({}) + runWebworker?.({ items: [], delimiter, sorting }) return } - if (isArray(nodes) && isEmpty(statusSelected)) { - let selectedLeaf: Maybe = nodes?.find(({ children = [] }) => children.length === 0) - - // if Keys folder not exists - first folder should be opened - if (!selectedLeaf && nodes.length) { - selectedLeaf = nodes?.[0] - - onStatusOpen?.(selectedLeaf?.fullName ?? '', true) - onStatusSelected?.( - `${selectedLeaf?.fullName + KEYS + delimiter + KEYS + delimiter}` ?? '', - selectedLeaf?.keys ?? selectedLeaf?.children?.[0]?.keys - ) - } else { - // if Keys folder exist - open it - onStatusSelected?.(selectedLeaf?.fullName ?? '', selectedLeaf?.keys) - } + setConstructingTree(true) + runWebworker?.({ items, delimiter, sorting }) + }, [items, delimiter]) - disableSelectDefaultLeaf?.() - onSelectLeaf?.(selectedLeaf?.keys ?? selectedLeaf?.children?.[0]?.keys ?? []) - } - }, [nodes, loading, selectDefaultLeaf]) + const handleUpdateSelected = useCallback((name: RedisString) => { + onStatusSelected?.(name) + }, [onStatusSelected]) - useEffect(() => { - if (isEmpty(statusSelected) || !nodes.length) { - return - } + const handleUpdateOpen = useCallback((fullName: string, value: boolean) => { + onStatusOpen?.(fullName, value) + }, [onStatusOpen, nodes]) - // if selected Keys folder is not exists (after a new search) needs reset Browser state - const selectedLeafExists = !!findTreeNode(nodes, Object.keys(statusSelected)?.[0], 'fullName') + const updateNodeByPath = (path: string, data: any) => { + const paths = path.replaceAll('.', '.children.') - if (!selectedLeafExists) { - dispatch(resetBrowserTree()) - } - }, [nodes]) + const node = get(nodes.current, paths) + const fullData = { ...node, ...data } - useEffect(() => { - if (!items?.length) { - setNodes([]) - runWebworker?.({ items: [], delimiter }) - return + if (node) { + set(nodes.current, paths, fullData) } + } - setConstructingTree(true) - runWebworker?.({ items, delimiter }) - }, [items, delimiter]) + const formatItem = useCallback((item: GetKeyInfoResponse) => ({ + ...item, + nameString: bufferToString(item.name as string) + }), []) - const handleSelectLeaf = useCallback((keys: any[]) => { - onSelectLeaf?.(keys) - }, [onSelectLeaf]) + const getMetadata = useCallback(( + itemsInit: any[] = [] + ): void => { + dispatch(fetchKeysMetadataTree( + itemsInit, + commonFilterType, + controller.current?.signal, + (loadedItems) => + onSuccessFetchedMetadata(loadedItems), + () => { rerender({}) } + )) + }, [commonFilterType]) - const handleUpdateSelected = useCallback((fullName: string, keys: any) => { - onStatusSelected?.(fullName, keys) - }, [onStatusSelected]) + const onSuccessFetchedMetadata = ( + loadedItems: any[], + ) => { + const items = loadedItems.map(formatItem) + + items.forEach((item) => updateNodeByPath(item.path, item)) + + rerender({}) + } - const handleUpdateOpen = useCallback((name: string, value: boolean) => { - onStatusOpen?.(name, value) - }, [onStatusOpen]) + const getMetadataDebounced = debounce(() => { + const entries = Object.entries(elements.current) + + getMetadata(entries) + }, 100) + + const getMetadataNode = (nameBuffer: any, path: string) => { + elements.current[path] = nameBuffer + getMetadataDebounced() + } // This helper function constructs the object that will be sent back at the step // [2] during the treeWalker function work. Except for the mandatory `data` @@ -157,18 +176,27 @@ const VirtualTree = (props: Props) => { ): TreeWalkerValue => ({ data: { id: node.id.toString(), - isLeaf: node.children?.length === 0, + isLeaf: node.isLeaf, keyCount: node.keyCount, name: node.name, + nameString: node.nameString, + nameBuffer: node.nameBuffer, + ttl: node.ttl, + size: node.size, + type: node.type, fullName: node.fullName, + shortName: node.shortName, nestingLevel, - setItems: handleSelectLeaf, + deleting, + path: node.path, + getMetadata: getMetadataNode, + onDeleteClicked, updateStatusSelected: handleUpdateSelected, updateStatusOpen: handleUpdateOpen, + onDelete: onDeleteLeaf, leafIcon: theme === Theme.Dark ? KeyDarkSVG : KeyLightSVG, keyApproximate: node.keyApproximate, - keys: node.keys || node?.[getTreeLeafField(delimiter)], - isSelected: Object.keys(statusSelected)[0] === node.fullName, + isSelected: !!node.isLeaf && statusSelected === node?.nameString, isOpenByDefault: statusOpen[node.fullName], }, nestingLevel, @@ -180,8 +208,8 @@ const VirtualTree = (props: Props) => { const treeWalker = useCallback( function* treeWalker(): ReturnType> { // Step [1]: Define the root multiple nodes of our tree - for (let i = 0; i < nodes.length; i++) { - yield getNodeData(nodes[i], 0) + for (let i = 0; i < nodes.current.length; i++) { + yield getNodeData(nodes.current[i], 0) } // Step [2]: Get the parent component back. It will be the object @@ -199,25 +227,38 @@ const VirtualTree = (props: Props) => { } } }, - [nodes, statusSelected], + [statusSelected, statusOpen, rerenderState], ) return ( {({ height, width }) => ( -
- { nodes.length > 0 && ( - - {Node} - +
+ { nodes.current.length > 0 && ( + <> + {loading && ( + + )} + + {Node} + + )} - { nodes.length === 0 && loading && ( + { nodes.current.length === 0 && loading && (
diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx b/redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx index 2b39ac5f23..427ecc8f77 100644 --- a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx +++ b/redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx @@ -5,7 +5,7 @@ import { render, screen } from 'uiSrc/utils/test-utils' import KeyDarkSVG from 'uiSrc/assets/img/sidebar/browser_active.svg' import Node from './Node' import { TreeData } from '../../interfaces' -import { mockLeafKeys, mockVirtualTreeResult } from '../../VirtualTree.spec' +import { mockVirtualTreeResult } from '../../VirtualTree.spec' const mockedProps = mock>() const mockedPropsData = mock() @@ -52,7 +52,6 @@ describe('Node', () => { }) it('"setItems", "updateStatusSelected" should be called after click on Leaf', () => { - const mockSetItems = jest.fn() const mockUpdateStatusSelected = jest.fn() const mockUpdateStatusOpen = jest.fn() const mockSetOpen = jest.fn() @@ -61,8 +60,6 @@ describe('Node', () => { ...mockedData, isLeaf: true, fullName: mockDataFullName, - keys: mockLeafKeys, - setItems: mockSetItems, updateStatusSelected: mockUpdateStatusSelected, updateStatusOpen: mockUpdateStatusOpen, } @@ -76,14 +73,12 @@ describe('Node', () => { screen.getByTestId(`node-item_${mockDataFullName}`).click() - expect(mockSetItems).toBeCalledWith(mockLeafKeys) - expect(mockUpdateStatusSelected).toBeCalledWith(mockDataFullName, mockLeafKeys) + expect(mockUpdateStatusSelected).toBeCalledWith(mockDataFullName) expect(mockUpdateStatusOpen).toBeCalledWith(mockDataFullName, true) expect(mockSetOpen).not.toBeCalled() }) it('"updateStatusOpen", "setOpen" should be called after click on Node', () => { - const mockSetItems = jest.fn() const mockUpdateStatusSelected = jest.fn() const mockUpdateStatusOpen = jest.fn() const mockSetOpen = jest.fn() @@ -93,8 +88,6 @@ describe('Node', () => { ...mockedData, isLeaf: mockIsOpen, fullName: mockDataFullName, - keys: mockLeafKeys, - setItems: mockSetItems, updateStatusSelected: mockUpdateStatusSelected, updateStatusOpen: mockUpdateStatusOpen, } @@ -108,7 +101,6 @@ describe('Node', () => { screen.getByTestId(`node-item_${mockDataFullName}`).click() - expect(mockSetItems).not.toBeCalled() expect(mockUpdateStatusSelected).not.toBeCalled() expect(mockUpdateStatusOpen).toHaveBeenCalledWith(mockDataFullName, !mockIsOpen) expect(mockSetOpen).toBeCalledWith(!mockIsOpen) diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx index 11850e3968..595e4af36c 100644 --- a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx +++ b/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx @@ -1,8 +1,31 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { NodePublicState } from 'react-vtree/dist/es/Tree' import cx from 'classnames' -import { EuiIcon, EuiToolTip, keys as ElasticKeys } from '@elastic/eui' +import { isUndefined } from 'lodash' +import { + EuiIcon, + EuiToolTip, + keys as ElasticKeys, + EuiButtonIcon, + EuiLoadingContent, + EuiText, + EuiTextColor, + EuiPopover, + EuiSpacer, + EuiButton, +} from '@elastic/eui' +import GroupBadge from 'uiSrc/components/group-badge' +import { + Maybe, + formatBytes, + formatLongName, + replaceSpaces, + truncateNumberToDuration, + truncateNumberToFirstUnit, + truncateTTLToSeconds, +} from 'uiSrc/utils' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' import { TreeData } from '../../interfaces' import styles from './styles.module.scss' @@ -12,38 +35,53 @@ import styles from './styles.module.scss' const Node = ({ data, isOpen, + index, style, - setOpen + setOpen, }: NodePublicState) => { const { + id: nodeId, isLeaf, - leafIcon, - keys, - name, keyCount, nestingLevel, fullName, + nameBuffer, + path, + type, + ttl, + size, + deleting, + shortName, + nameString, keyApproximate, isSelected, - setItems, + getMetadata, + onDelete, + onDeleteClicked, updateStatusOpen, updateStatusSelected, } = data + const [deletePopoverId, setDeletePopoverId] = useState>(undefined) + + useEffect(() => { + if (!size && isLeaf && nameBuffer) { + getMetadata(nameBuffer, path) + } + }, []) + useEffect(() => { - if (isSelected && keys) { - updateStatusSelected?.(fullName, keys) + if (isSelected && nameBuffer) { + updateStatusSelected?.(nameBuffer) } - }, [keys, isSelected]) + }, [isSelected]) const handleClick = () => { - if (isLeaf && keys && !isSelected) { - setItems?.(keys) - updateStatusSelected?.(fullName, keys) + if (isLeaf && !isSelected) { + updateStatusSelected?.(nameBuffer) } updateStatusOpen?.(fullName, !isOpen) - !isLeaf && setOpen(!isOpen) } @@ -53,6 +91,216 @@ const Node = ({ } } + const handleDelete = () => { + onDelete(nameBuffer) + setDeletePopoverId(undefined) + } + + const handleDeletePopoverOpen = (index: Maybe, type: KeyTypes | ModulesKeyTypes) => { + if (index !== deletePopoverId) { + onDeleteClicked(type) + } + setDeletePopoverId(index !== deletePopoverId ? index : undefined) + } + + const Folder = () => ( + <> +
+ + + {nameString} +
+
+
+ {keyApproximate ? `${keyApproximate < 1 ? '<1' : Math.round(keyApproximate)}%` : '' } +
+
{keyCount ?? ''}
+
+ + ) + + const Leaf = () => ( + <> + + + + + + ) + + const LeafType = () => ( + <> + {!type && } + {!!type &&
} + + ) + + const LeafName = () => { + // Better to cut the long string, because it could affect virtual scroll performance + const nameContent = replaceSpaces(shortName?.substring?.(0, 200)) + const nameTooltipContent = formatLongName(nameString) + + return ( +
+ +
+ + <>{nameContent} + +
+
+
+ ) + } + + const LeafTTL = () => { + if (isUndefined(ttl)) { + return + } + if (ttl === -1) { + return ( + + No limit + + ) + } + return ( + +
+ + {`${truncateTTLToSeconds(ttl)} s`} +
+ {`(${truncateNumberToDuration(ttl)})`} + + )} + > + <>{truncateNumberToFirstUnit(ttl)} +
+
+
+ ) + } + + const LeafSize = () => { + if (isUndefined(size)) { + return + } + + if (!size) { + return ( + + - + + ) + } + return ( + <> + +
+ + {formatBytes(size, 3)} + + )} + > + <>{formatBytes(size, 0)} + +
+
+ setDeletePopoverId(undefined)} + panelPaddingSize="l" + panelClassName={styles.deletePopover} + button={( + handleDeletePopoverOpen(nodeId, type)} + aria-label="Delete Key" + data-testid={`delete-key-btn-${nameString}`} + /> + )} + onClick={(e) => e.stopPropagation()} + > + <> + +

{formatLongName(nameString)}

+ will be deleted. +
+ + + Delete + + +
+ + ) + } + const Node = (
{}} data-testid={`node-item_${fullName}`} > -
- {!isLeaf && ( - <> - - - {name} - - )} - - {isLeaf && ( - <> - - Keys - - )} -
-
- {keyCount ?? ''} - - {keyApproximate ? `${keyApproximate < 1 ? '<1' : Math.round(keyApproximate)}%` : '' } - -
+ {!isLeaf && } + {isLeaf && }
) - const content = ( + const tooltipContent = ( <> {`${fullName}*`}
@@ -116,12 +332,17 @@ const Node = ({ ...style, paddingLeft: nestingLevel * 8, }} - className={cx(styles.nodeContainer, { [styles.nodeSelected]: isSelected && isLeaf, })} + className={cx( + styles.nodeContainer, { + [styles.nodeSelected]: isSelected && isLeaf, + [styles.nodeRowEven]: index % 2 === 0, + } + )} > {isLeaf && Node} {!isLeaf && ( diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss b/redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss index 38e9aabe51..1af538d04f 100644 --- a/redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss +++ b/redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss @@ -1,5 +1,6 @@ .anchorTooltipNode { width: 100%; + height: 42px; display: inline-block; position: relative; } @@ -12,36 +13,62 @@ } } +.nodeRowEven { + background-color: var(--browserTableRowEven); +} + .nodeContent { display: flex; justify-content: space-between; cursor: pointer; - padding-right: 8px; + padding: 8px 16px; color: var(--euiTextSubduedColor) !important; - font: normal normal normal 13px/28px Graphik, sans-serif !important; + align-items: center; + font: + normal normal normal 13px/28px Graphik, + sans-serif !important; letter-spacing: -0.13px; + overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; + height: 100%; &Open { color: var(--euiColorFullShade) !important; } + + .moveOnHover { + transition: transform ease 0.3s; + &.hide { + transform: translateX(-8px); + } + } + .showOnHover { + display: none; + &.show { + display: flex !important; + } + } + + &:hover { + .moveOnHover { + transform: translateX(-8px); + } + .showOnHover { + display: flex; + } + } } .nodeSelected { border-left-color: var(--euiColorPrimary) !important; - background-color: var(--browserComponentActive); + background-color: var(--browserComponentActive) !important; .nodeContent { color: var(--euiColorFullShade) !important; } } -.nodeName { - position: relative; - overflow: hidden; - text-overflow: ellipsis; -} - .nodeIcon { margin-right: 8px; @@ -60,10 +87,9 @@ } } -.approximate { +.approximate, +.keyCount { display: inline-block; - width: 36px; - text-align: end; } .options { @@ -71,3 +97,40 @@ font-size: 12px; font-weight: 300; } + +.keyType { + padding-right: 16px; + padding-left: 12px; + width: 126px; + min-width: 126px; +} + +.keyName { + flex-grow: 1; + position: relative; + overflow: hidden; + text-overflow: ellipsis; +} + +.keyTTL, +.approximate { + width: 86px; + min-width: 86px; + text-align: right; +} + +.keySize, +.keyCount { + width: 90px; + min-width: 90px; + text-align: right; +} + +.keyInfoLoading { + margin-top: 8px; + padding-left: 16px; +} + +.deletePopover { + max-width: 400px !important; +} diff --git a/redisinsight/ui/src/components/virtual-tree/interfaces.ts b/redisinsight/ui/src/components/virtual-tree/interfaces.ts index d2b1c21d26..717cd34fe4 100644 --- a/redisinsight/ui/src/components/virtual-tree/interfaces.ts +++ b/redisinsight/ui/src/components/virtual-tree/interfaces.ts @@ -1,5 +1,6 @@ import { FixedSizeNodeData } from 'react-vtree' -import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' export interface TreeNode { children: TreeNode[] @@ -24,12 +25,11 @@ export interface NodeMetaData { keyCount: number, name: string, fullName: string, - setItems: (keys: any[]) => void, + shortName: string, updateStatusSelected: (fullName: string, keys: any) => void, updateStatusOpen: (name: string, value: boolean) => void, leafIcon: string, keyApproximate: number, - keys: any, isSelected: boolean, isOpenByDefault: boolean, } @@ -37,14 +37,24 @@ export interface NodeMetaData { export interface TreeData extends FixedSizeNodeData { isLeaf: boolean name: string + nameString: string + nameBuffer: RedisResponseBuffer + path: string keyCount: number keyApproximate: number fullName: string + shortName: string leafIcon: string - keys: IKeyPropTypes[] + type: KeyTypes | ModulesKeyTypes + ttl: number + size: number nestingLevel: number + deleting: boolean isSelected: boolean - setItems: (keys: any[]) => void + children?: TreeData[] updateStatusOpen: (fullName: string, value: boolean) => void - updateStatusSelected: (fullName: string, keys: IKeyPropTypes[]) => void + updateStatusSelected: (key: RedisString) => void + getMetadata: (key: RedisString, path: string) => void + onDelete: (key: RedisResponseBuffer) => void + onDeleteClicked: (type: KeyTypes | ModulesKeyTypes) => void } diff --git a/redisinsight/ui/src/components/virtual-tree/styles.module.scss b/redisinsight/ui/src/components/virtual-tree/styles.module.scss index 88595be6ce..64ec904730 100644 --- a/redisinsight/ui/src/components/virtual-tree/styles.module.scss +++ b/redisinsight/ui/src/components/virtual-tree/styles.module.scss @@ -36,3 +36,7 @@ top: 12px; left: 12px; } + +.progress { + z-index: 2; +} diff --git a/redisinsight/ui/src/constants/browser.ts b/redisinsight/ui/src/constants/browser.ts index c56384dafd..83e31d1232 100644 --- a/redisinsight/ui/src/constants/browser.ts +++ b/redisinsight/ui/src/constants/browser.ts @@ -1,6 +1,7 @@ -import { KeyValueFormat } from './keys' +import { KeyValueFormat, SortOrder } from './keys' export const DEFAULT_DELIMITER = ':' +export const DEFAULT_TREE_SORTING = SortOrder.ASC export const DEFAULT_SHOW_HIDDEN_RECOMMENDATIONS = false export const TEXT_UNPRINTABLE_CHARACTERS = { diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index 70b23501ee..18e10c6b4d 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -1,12 +1,14 @@ +import { SortOrder } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' interface Props { items: IKeyPropTypes[] delimiter?: string + sorting?: SortOrder } export const constructKeysToTree = (props: Props): any[] => { - const { items: keys, delimiter = ':' } = props + const { items: keys, delimiter = ':', sorting = 'ASC' } = props const keysSymbol = `keys${delimiter}keys` const tree: any = {} @@ -20,11 +22,8 @@ export const constructKeysToTree = (props: Props): any[] => { nameSplitted.forEach((value:any, index: number) => { // create a key leaf if (index === lastIndex) { - if (currentNode[keysSymbol] === undefined) { - currentNode[keysSymbol] = {} - } - - currentNode[keysSymbol][name] = key + // eslint-disable-next-line prefer-object-spread + currentNode[name + keysSymbol] = Object.assign({}, key, { isLeaf: true }) } else if (currentNode[value] === undefined) { currentNode[value] = {} } @@ -47,39 +46,62 @@ export const constructKeysToTree = (props: Props): any[] => { return candidateId } - // FormatTreeData - const formatTreeData = (tree: any, previousKey = '', delimiter = ':') => { - const treeNodes = Reflect.ownKeys(tree) - - // sort Ungrouped Keys group to top - treeNodes.some((key, index) => { - if (key === keysSymbol) { - const temp = treeNodes[0] - treeNodes[0] = key - treeNodes[index] = temp - return true + // Folders should be always before leaves + const sortKeysAndFolder = (nodes: string[]) => { + nodes.sort((a, b) => { + // Custom sorting for items ending with "keys:keys" + if (a.endsWith(keysSymbol) && !b.endsWith(keysSymbol)) { + return 1 + } + if (!a.endsWith(keysSymbol) && b.endsWith(keysSymbol)) { + return -1 + } + + // Regular sorting + if (sorting === 'ASC') { + return a.localeCompare(b, 'en', { numeric: true }) + } + if (sorting === 'DESC') { + return b.localeCompare(a, 'en', { numeric: true }) } - return false + + return 0 }) + } + + // FormatTreeData + const formatTreeData = (tree: any, previousKey = '', delimiter = ':', prevIndex = '') => { + const treeNodes: string[] = Object.keys(tree) + + sortKeysAndFolder(treeNodes) - return treeNodes.map((key) => { + return treeNodes.map((key, index) => { const name = key?.toString() - const node: any = { name } + const node: any = { nameString: name } const tillNowKeyName = previousKey + name + delimiter + const path = prevIndex ? `${prevIndex}.${index}` : `${index}` // populate node with children nodes - if (key !== keysSymbol && Reflect.ownKeys(tree[key]).length > 0) { - node.children = formatTreeData(tree[key], tillNowKeyName, delimiter) - node.keyCount = node.children.reduce((a: any, b:any) => a + (b.keyCount || 0), 0) + if (!tree[key].isLeaf && Object.keys(tree[key]).length > 0) { + node.children = formatTreeData( + tree[key], + tillNowKeyName, + delimiter, + path, + ) + node.keyCount = node.children.reduce((a: any, b:any) => a + (b.keyCount || 1), 0) + node.keyApproximate = (node.keyCount / keys.length) * 100 } else { - // populate leaf with keys + // populate leaf + node.isLeaf = true node.children = [] - node.keys = tree[keysSymbol] ?? [] - node.keyCount = Object.keys(node.keys ?? [])?.length ?? 1 + node.nameString = name.slice(0, -keysSymbol.length) + node.nameBuffer = tree[key]?.name + node.shortName = node.nameString?.slice(previousKey.length) } + node.path = path node.fullName = tillNowKeyName - node.keyApproximate = (node.keyCount / keys.length) * 100 node.id = getUniqueId() return node }) diff --git a/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts b/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts index 0ced2705c5..53db673148 100644 --- a/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts +++ b/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts @@ -1,133 +1,118 @@ -import { DEFAULT_DELIMITER } from 'uiSrc/constants' -import { getTreeLeafField } from 'uiSrc/utils' - export const constructKeysToTreeMockResult = [ { - name: getTreeLeafField(DEFAULT_DELIMITER), - children: [], - keys: { - keys2: { - nameString: 'keys2', - type: 'hash', - ttl: -1, - size: 71 - }, - test1: { - nameString: 'test1', - type: 'hash', - ttl: -1, - size: 71 - }, - test2: { - nameString: 'test2', - type: 'hash', - ttl: -1, - size: 71 - }, - keys1: { - nameString: 'keys1', - type: 'hash', - ttl: -1, - size: 71 - } - }, - keyCount: 4, - fullName: `${getTreeLeafField(DEFAULT_DELIMITER)}:`, - keyApproximate: 40, - }, - { - name: 'keys', + nameString: 'empty', children: [ { - name: getTreeLeafField(DEFAULT_DELIMITER), - children: [], - keys: { - 'keys:1': { - nameString: 'keys:1', - type: 'hash', - ttl: -1, - size: 71 - }, - 'keys:3': { - nameString: 'keys:3', - type: 'hash', - ttl: -1, - size: 71 - }, - 'keys:2': { - nameString: 'keys:2', - type: 'hash', - ttl: -1, - size: 71 - } - }, - keyCount: 3, - fullName: `keys:${getTreeLeafField(DEFAULT_DELIMITER)}:`, - keyApproximate: 30, - }, - { - name: '1', + nameString: '', children: [ { - name: getTreeLeafField(DEFAULT_DELIMITER), + nameString: 'empty::test', + isLeaf: true, children: [], - keys: { - 'keys:1:2': { - nameString: 'keys:1:2', - type: 'hash', - ttl: -1, - size: 71 - }, - 'keys:1:1': { - nameString: 'keys:1:1', - type: 'hash', - ttl: -1, - size: 71 - } - }, - keyCount: 2, - fullName: `keys:1:${getTreeLeafField(DEFAULT_DELIMITER)}:`, - keyApproximate: 20, + shortName: 'test', + path: '0.0.0', + fullName: 'empty::empty::testkeys:keys:', } ], - keyCount: 2, - fullName: 'keys:1:', - keyApproximate: 20, + keyCount: 1, + keyApproximate: 10, + path: '0.0', + fullName: 'empty::', } ], - keyCount: 5, - fullName: 'keys:', - keyApproximate: 50, + keyCount: 1, + keyApproximate: 10, + path: '0', + fullName: 'empty:', }, { - name: 'empty', + nameString: 'keys', children: [ { - name: '', + nameString: '1', children: [ { - name: getTreeLeafField(DEFAULT_DELIMITER), + nameString: 'keys:1:1', + isLeaf: true, + children: [], + shortName: '1', + path: '1.0.0', + fullName: 'keys:1:keys:1:1keys:keys:', + }, + { + nameString: 'keys:1:2', + isLeaf: true, children: [], - keys: { - 'empty::test': { - nameString: 'empty::test', - type: 'hash', - ttl: -1, - size: 71 - } - }, - keyCount: 1, - fullName: `empty::${getTreeLeafField(DEFAULT_DELIMITER)}:`, - keyApproximate: 10, + shortName: '2', + path: '1.0.1', + fullName: 'keys:1:keys:1:2keys:keys:', } ], - keyCount: 1, - fullName: 'empty::', - keyApproximate: 10, + keyCount: 2, + keyApproximate: 20, + path: '1.0', + fullName: 'keys:1:', + }, + { + nameString: 'keys:1', + isLeaf: true, + children: [], + shortName: '1', + path: '1.1', + fullName: 'keys:keys:1keys:keys:', + }, + { + nameString: 'keys:2', + isLeaf: true, + children: [], + shortName: '2', + path: '1.2', + fullName: 'keys:keys:2keys:keys:', + }, + { + nameString: 'keys:3', + isLeaf: true, + children: [], + shortName: '3', + path: '1.3', + fullName: 'keys:keys:3keys:keys:', } ], - keyCount: 1, - fullName: 'empty:', - keyApproximate: 10, + keyCount: 5, + keyApproximate: 50, + path: '1', + fullName: 'keys:', + }, + { + nameString: 'keys1', + isLeaf: true, + children: [], + shortName: 'keys1', + path: '2', + fullName: 'keys1keys:keys:', + }, + { + nameString: 'keys2', + isLeaf: true, + children: [], + shortName: 'keys2', + path: '3', + fullName: 'keys2keys:keys:', + }, + { + nameString: 'test1', + isLeaf: true, + children: [], + shortName: 'test1', + path: '4', + fullName: 'test1keys:keys:', + }, + { + nameString: 'test2', + isLeaf: true, + children: [], + shortName: 'test2', + path: '5', + fullName: 'test2keys:keys:', } ] 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 df1ad59ddc..17b02065e1 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 @@ -56,6 +56,7 @@ const BrowserLeftPanel = (props: Props) => { searchMode, isSearched: patternIsSearched, filter, + deleting, } = useSelector(keysSelector) const { contextInstanceId } = useSelector(appContextSelector) const { @@ -154,6 +155,7 @@ const BrowserLeftPanel = (props: Props) => { selectKey={selectKey} loadMoreItems={loadMoreItems} onDelete={onDeleteKey} + deleting={deleting} onAddKeyPanel={handleAddKeyPanel} onBulkActionsPanel={handleBulkActionsPanel} /> 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 50fb490b05..9d9e911cb7 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 @@ -8,7 +8,6 @@ import BulkActions from 'uiSrc/pages/browser/components/bulk-actions' import CreateRedisearchIndex from 'uiSrc/pages/browser/components/create-redisearch-index/' import KeyDetailsWrapper from 'uiSrc/pages/browser/components/key-details/KeyDetailsWrapper' -import { updateBrowserTreeSelectedLeaf } from 'uiSrc/slices/app/context' import { keysDataSelector, keysSelector, @@ -16,9 +15,8 @@ import { toggleBrowserFullScreen } from 'uiSrc/slices/browser/keys' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { bufferToString, Nullable } from 'uiSrc/utils' +import { Nullable } from 'uiSrc/utils' export interface Props { selectedKey: Nullable @@ -91,10 +89,6 @@ const BrowserRightPanel = (props: Props) => { const handleEditKey = (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => { setSelectedKey(newKey) - - if (viewType === KeyViewType.Tree) { - dispatch(updateBrowserTreeSelectedLeaf({ key: bufferToString(key), newKey: bufferToString(newKey) })) - } } const onEditKey = useCallback( diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx index 2f131f4361..f75df5ae4c 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx @@ -18,6 +18,7 @@ import { isVersionHigherOrEquals } from 'uiSrc/utils' import { KeyViewType } from 'uiSrc/slices/interfaces/keys' import { FilterNotAvailable } from 'uiSrc/components' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { resetBrowserTree } from 'uiSrc/slices/app/context' import { FILTER_KEY_TYPE_OPTIONS } from './constants' import styles from './styles.module.scss' @@ -74,6 +75,7 @@ const FilterKeyType = () => { setTypeSelected(value) setIsSelectOpen(false) dispatch(setFilter(value === ALL_KEY_TYPES_VALUE ? null : value)) + viewType === KeyViewType.Tree && dispatch(resetBrowserTree()) dispatch( fetchKeys( { diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx index e89c3a8f89..f72f3d0739 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx @@ -258,8 +258,8 @@ const KeyDetailsHeader = ({ setTTLIsEditing(false) setTTLIsHovering(false) - if (`${ttlProp}` !== ttlValue) { - onEditTTL(keyProp, +ttlValue) + if (`${ttlProp}` !== ttlValue && keyBuffer) { + onEditTTL(keyBuffer, +ttlValue) } } diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx index 4407f212bf..f238dfcac5 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -53,13 +53,13 @@ import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' import { KeyTypes, ModulesKeyTypes, TableCellAlignment, TableCellTextAlignment } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' -import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import NoKeysFound from 'uiSrc/pages/browser/components/no-keys-found' import { GetKeyInfoResponse } from 'apiSrc/modules/browser/dto' +import NoKeysMessage from '../no-keys-message' import styles from './styles.module.scss' export interface Props { @@ -155,42 +155,25 @@ const KeyList = forwardRef((props: Props, ref) => { controller.current?.abort() } + const NoItemsMessage = () => ( + + ) + const getNoItemsMessage = () => { if (isNotRendered.current) { return '' } - if (searchMode === SearchMode.Redisearch) { - if (!selectedIndex) { - return NoSelectedIndexText - } - - if (total === 0) { - return NoResultsFoundText - } - - if (isSearched) { - return keysState.scanned < total ? NoResultsFoundText : FullScanNoResultsFoundText - } - } - - if (total === 0) { - return () - } - - if (isSearched) { - return keysState.scanned < total ? ScanNoResultsFoundText : FullScanNoResultsFoundText - } - - if (isFiltered && keysState.scanned < total) { - return ScanNoResultsFoundText - } - if (itemsRef.current.length < keysState.keys.length) { return 'loading...' } - return NoResultsFoundText + return } const onLoadMoreItems = (props: { startIndex: number, stopIndex: number }) => { @@ -223,11 +206,7 @@ const KeyList = forwardRef((props: Props, ref) => { const handleDeletePopoverOpen = (index: Maybe, type: KeyTypes | ModulesKeyTypes) => { if (index !== deletePopoverIndex) { sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_DELETE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_DELETE_CLICKED - ), + event: TelemetryEvent.BROWSER_KEY_DELETE_CLICKED, eventData: { databaseId: instanceId, keyType: type, diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx index a38b56f5f9..60435f70ab 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx @@ -2,17 +2,10 @@ import { cloneDeep } from 'lodash' import React from 'react' import { cleanup, - clearStoreActions, - fireEvent, mockedStore, render, - screen, - act, } from 'uiSrc/utils/test-utils' -import { setSearchMatch } from 'uiSrc/slices/browser/keys' import { KeysStoreData } from 'uiSrc/slices/interfaces/keys' -import { mockVirtualTreeResult } from 'uiSrc/components/virtual-tree/VirtualTree.spec' -import { setBrowserTreeNodesOpen, setBrowserTreeSelectedLeaf } from 'uiSrc/slices/app/context' import KeyTree from './KeyTree' let store: typeof mockedStore @@ -58,10 +51,6 @@ const propsMock = { selectKey: jest.fn(), } -const mockLeafKeys = { - test: { name: 'test', type: 'hash', ttl: -1, size: 9849176 } -} - const mockWebWorkerResult = [{ children: [{ children: [], @@ -84,7 +73,6 @@ const mockWebWorkerResult = [{ keyApproximate: 0.01, keyCount: 1, name: 'test', - keys: mockLeafKeys }] jest.mock('uiSrc/services', () => ({ @@ -93,13 +81,7 @@ jest.mock('uiSrc/services', () => ({ })) describe('KeyTree', () => { - it('Key tree delimiter should be in the document', () => { - render() - - expect(screen.getByTestId('tree-view-delimiter-btn')).toBeInTheDocument() - }) - - it('Tree view panel should be in the document', () => { + it.only('Tree view panel should be in the document', () => { const { container } = render() expect(container.querySelector('[data-test-subj="tree-view-panel"]')).toBeInTheDocument() @@ -110,42 +92,4 @@ describe('KeyTree', () => { expect(container.querySelector('[data-test-subj="key-list-panel"]')).toBeInTheDocument() }) - - it.skip('"setBrowserTreeNodesOpen" should be called for Open a node', async () => { - jest.useFakeTimers() - render() - - await act(() => { - jest.advanceTimersByTime(1000) - }) - - await act(() => { - fireEvent.click(screen.getByTestId(`node-item_${mockVirtualTreeResult?.[0]?.fullName}`)) - }) - - const expectedActions = [ - setBrowserTreeSelectedLeaf({}), - setBrowserTreeNodesOpen({}) - ] - - expect(clearStoreActions(store.getActions())).toEqual( - clearStoreActions(expectedActions) - ) - }) - - it.skip('"setSearchMatch" should be called after "onChange"', () => { - const searchTerm = 'a' - - render() - - fireEvent.change(screen.getByTestId('search-key'), { - target: { value: searchTerm }, - }) - - const expectedActions = [setSearchMatch(searchTerm)] - - expect(clearStoreActions(store.getActions())).toEqual( - clearStoreActions(expectedActions) - ) - }) }) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx index 83401aff22..b289631390 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx @@ -1,15 +1,13 @@ -import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState, useTransition } from 'react' -import cx from 'classnames' -import { EuiResizableContainer } from '@elastic/eui' +import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { isEmpty } from 'lodash' +import cx from 'classnames' +import { useParams } from 'react-router-dom' import { appContextBrowserTree, resetBrowserTree, appContextDbConfig, setBrowserTreeNodesOpen, - setBrowserTreeSelectedLeaf } from 'uiSrc/slices/app/context' import { constructKeysToTree } from 'uiSrc/helpers' import VirtualTree from 'uiSrc/components/virtual-tree' @@ -17,17 +15,19 @@ import TreeViewSVG from 'uiSrc/assets/img/icons/treeview.svg' import { KeysStoreData } from 'uiSrc/slices/interfaces/keys' import { Nullable, bufferToString } from 'uiSrc/utils' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' -import { KeyTypes } from 'uiSrc/constants' -import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' +import { deleteKeyAction, selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { GetKeyInfoResponse } from 'apiSrc/modules/browser/dto' -import KeyTreeDelimiter from './KeyTreeDelimiter' -import KeyList from '../key-list' +import NoKeysMessage from '../no-keys-message' import styles from './styles.module.scss' export interface Props { keysState: KeysStoreData loading: boolean + deleting: boolean commonFilterType: Nullable selectKey: ({ rowData }: { rowData: any }) => void loadMoreItems: ( @@ -54,24 +54,19 @@ const KeyTree = forwardRef((props: Props, ref) => { keysState, onDelete, commonFilterType, + deleting, onAddKeyPanel, - onBulkActionsPanel + onBulkActionsPanel, } = props - const firstPanelId = 'tree' - const secondPanelId = 'keys' - - const { panelSizes, openNodes, selectedLeaf } = useSelector(appContextBrowserTree) - const { treeViewDelimiter: delimiter = '' } = useSelector(appContextDbConfig) + const { instanceId } = useParams<{ instanceId: string }>() + const { openNodes } = useSelector(appContextBrowserTree) + const { treeViewDelimiter: delimiter = '', treeViewSort: sorting } = useSelector(appContextDbConfig) + const { nameString: selectedKeyName = null } = useSelector(selectedKeyDataSelector) ?? {} - const [,startTransition] = useTransition() - - const [statusSelected, setStatusSelected] = useState(selectedLeaf) const [statusOpen, setStatusOpen] = useState(openNodes) - const [sizes, setSizes] = useState(panelSizes) - const [keyListState, setKeyListState] = useState(keysState) const [constructingTree, setConstructingTree] = useState(false) - const [selectDefaultLeaf, setSelectDefaultLeaf] = useState(isEmpty(selectedLeaf)) + const [firstDataLoaded, setFirstDataLoaded] = useState(!!keysState.keys.length) const [items, setItems] = useState(parseKeyNames(keysState.keys ?? [])) const dispatch = useDispatch() @@ -83,19 +78,27 @@ const KeyTree = forwardRef((props: Props, ref) => { })) useEffect(() => { - updateKeysList() + openSelectedKey(selectedKeyName) }, []) useEffect(() => { setStatusOpen(openNodes) }, [openNodes]) - useEffect(() => { - setStatusSelected(selectedLeaf) - updateKeysList(Object.values(selectedLeaf)?.[0]) + // open all parents for selected key + const openSelectedKey = (selectedKeyName: Nullable = '') => { + if (selectedKeyName) { + const parts = selectedKeyName.split(delimiter) + const parents = parts.map((_, index) => parts.slice(0, index + 1).join(delimiter) + delimiter) - setSelectDefaultLeaf(isEmpty(selectedLeaf)) - }, [selectedLeaf]) + // remove key name from parents + parents.pop() + + setTimeout(() => { + parents.forEach((parent) => handleStatusOpen(parent, true)) + }, 0) + } + } useEffect(() => { setItems(parseKeyNames(keysState.keys)) @@ -106,8 +109,13 @@ const KeyTree = forwardRef((props: Props, ref) => { }, [keysState.keys]) useEffect(() => { + setFirstDataLoaded(true) setItems(parseKeyNames(keysState.keys)) - }, [delimiter, keysState.lastRefreshTime]) + }, [sorting, delimiter, keysState.lastRefreshTime]) + + useEffect(() => { + openSelectedKey(selectedKeyName) + }, [selectedKeyName]) const onLoadMoreItems = (props: { startIndex: number, stopIndex: number }) => { const formattedAllKeys = parseKeyNames(keysState.keys) @@ -117,38 +125,14 @@ const KeyTree = forwardRef((props: Props, ref) => { // select default leaf "Keys" after each change delimiter, filter or search const updateSelectedKeys = () => { dispatch(resetBrowserTree()) - - setTimeout(() => { - startTransition(() => { - setStatusSelected({}) - setSelectDefaultLeaf(true) - }) - }, 0) + openSelectedKey(selectedKeyName) } - const updateKeysList = (items:any = {}) => { - startTransition(() => { - const newState:KeysStoreData = { - ...keyListState, - keys: Object.values(items) - } - - setKeyListState(newState) - }) - } - - const onPanelWidthChange = useCallback((newSizes: any) => { - setSizes((prevSizes: any) => ({ - ...prevSizes, - ...newSizes, - })) - }, []) - const handleStatusOpen = (name: string, value:boolean) => { setStatusOpen((prevState) => { const newState = { ...prevState } // add or remove opened node - if (newState[name]) { + if (!value) { delete newState[name] } else { newState[name] = value @@ -159,85 +143,72 @@ const KeyTree = forwardRef((props: Props, ref) => { }) } - const handleStatusSelected = (fullName: string, keys: any) => { - dispatch(setBrowserTreeSelectedLeaf({ [fullName]: keys })) + const handleStatusSelected = (name: RedisString) => { + selectKey({ rowData: { name } }) + } + + const handleDeleteLeaf = (key: RedisResponseBuffer) => { + dispatch(deleteKeyAction(key, () => { + onDelete(key) + })) + } + + const handleDeleteClicked = (type: KeyTypes | ModulesKeyTypes) => { + sendEventTelemetry({ + event: TelemetryEvent.TREE_VIEW_KEY_DELETE_CLICKED, + eventData: { + databaseId: instanceId, + keyType: type, + source: 'keyList' + } + }) + } + + if (keysState.keys.length === 0) { + const NoItemsMessage = () => { + if (loading || !firstDataLoaded) { + return loading... + } + + return ( + + ) + } + + return ( +
+
+ +
+
+ ) } return ( -
+
-
- - {(EuiResizablePanel, EuiResizableButton) => ( - <> - -
-
- -
-
- setSelectDefaultLeaf(false)} - /> -
-
-
- - - - -
- -
-
- - )} -
-
+
) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.spec.tsx deleted file mode 100644 index 5df4b8cebd..0000000000 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.spec.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { cloneDeep } from 'lodash' -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { DEFAULT_DELIMITER } from 'uiSrc/constants' -import { resetBrowserTree, setBrowserTreeDelimiter } from 'uiSrc/slices/app/context' -import { - cleanup, - clearStoreActions, - fireEvent, - mockedStore, - render, - screen, - act, -} from 'uiSrc/utils/test-utils' - -import KeyTreeDelimiter, { Props } from './KeyTreeDelimiter' - -const mockedProps = mock() -let store: typeof mockedStore -const INLINE_ITEM_EDITOR = 'inline-item-editor' -const INLINE_EDITOR_APPLY_BTN = 'apply-btn' -const DELIMITER_TRIGGER_BTN = 'tree-view-delimiter-btn' -const DELIMITER_INPUT = 'tree-view-delimiter-input' - -beforeEach(() => { - cleanup() - store = cloneDeep(mockedStore) - store.clearActions() -}) - -jest.mock('uiSrc/services', () => ({ - localStorageService: { - set: jest.fn(), - get: jest.fn(), - }, -})) - -describe('KeyTreeDelimiter', () => { - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('Delimiter button should be rendered', () => { - render() - - expect(screen.getByTestId(DELIMITER_TRIGGER_BTN)).toBeInTheDocument() - }) - - it('Delimiter input should be rendered after click on button', async () => { - render() - - await act(() => { - fireEvent.click(screen.getByTestId(DELIMITER_TRIGGER_BTN)) - }) - - expect(screen.getByTestId(DELIMITER_INPUT)).toBeInTheDocument() - }) - - it('"setBrowserTreeDelimiter" should be called after Apply change delimiter', async () => { - const value = 'val' - render() - - await act(() => { - fireEvent.click(screen.getByTestId(DELIMITER_TRIGGER_BTN)) - }) - - fireEvent.change(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value } }) - - await act(() => { - fireEvent.click(screen.getByTestId(INLINE_EDITOR_APPLY_BTN)) - }) - - const expectedActions = [ - setBrowserTreeDelimiter(value), - resetBrowserTree(), - ] - - expect(clearStoreActions(store.getActions())).toEqual( - clearStoreActions(expectedActions) - ) - }) - - it('"setBrowserTreeDelimiter" should be called with DEFAULT_DELIMITER after Apply change with empty input', async () => { - const value = '' - render() - - await act(() => { - fireEvent.click(screen.getByTestId(DELIMITER_TRIGGER_BTN)) - }) - - fireEvent.change(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value } }) - - await act(() => { - fireEvent.click(screen.getByTestId(INLINE_EDITOR_APPLY_BTN)) - }) - - const expectedActions = [ - setBrowserTreeDelimiter(DEFAULT_DELIMITER), - resetBrowserTree(), - ] - - expect(clearStoreActions(store.getActions())).toEqual( - clearStoreActions(expectedActions) - ) - }) -}) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.tsx deleted file mode 100644 index 844065037a..0000000000 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/KeyTreeDelimiter.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useState } from 'react' -import cx from 'classnames' -import { useDispatch, useSelector } from 'react-redux' -import { useParams } from 'react-router-dom' -import { EuiIcon, EuiPopover } from '@elastic/eui' - -import { replaceSpaces } from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import InlineItemEditor from 'uiSrc/components/inline-item-editor' -import { DEFAULT_DELIMITER } from 'uiSrc/constants' -import { appContextDbConfig, resetBrowserTree, setBrowserTreeDelimiter } from 'uiSrc/slices/app/context' - -import styles from './styles.module.scss' - -export interface Props { - loading: boolean -} -const MAX_DELIMITER_LENGTH = 5 -const KeyTreeDelimiter = ({ loading }: Props) => { - const { instanceId = '' } = useParams<{ instanceId: string }>() - const { treeViewDelimiter: delimiter = '' } = useSelector(appContextDbConfig) - const [isPopoverOpen, setIsPopoverOpen] = useState(false) - - const dispatch = useDispatch() - - const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen) - const closePopover = () => setIsPopoverOpen(false) - - const button = ( -
{}} - data-testid="tree-view-delimiter-btn" - > - {replaceSpaces(delimiter)} - -
- ) - - const handleApplyDelimiter = (value: string) => { - sendEventTelemetry({ - event: TelemetryEvent.TREE_VIEW_DELIMITER_CHANGED, - eventData: { - databaseId: instanceId, - from: delimiter, - to: value || DEFAULT_DELIMITER - } - }) - closePopover() - dispatch(setBrowserTreeDelimiter(value || DEFAULT_DELIMITER)) - - dispatch(resetBrowserTree()) - } - - return ( -
- -
Delimiter
-
- closePopover()} - onApply={(value) => handleApplyDelimiter(value)} - /> -
-
-
- ) -} - -export default React.memo(KeyTreeDelimiter) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/index.ts b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/index.ts deleted file mode 100644 index 88dc65dd7b..0000000000 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import KeyTreeDelimiter from './KeyTreeDelimiter' - -export default KeyTreeDelimiter diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss deleted file mode 100644 index 3320d27cc7..0000000000 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss +++ /dev/null @@ -1,68 +0,0 @@ -$selectDelimiterHeight: 18px; - -.anchorBtn { - width: 60px; - height: $selectDelimiterHeight; - margin-left: 6px; - padding-left: 6px; - margin-bottom: 10px; - border: 1px solid var(--separatorColor) !important; - border-radius: 4px; - font-size: 12px; - line-height: 16px; - - &Open { - border-bottom: 2px solid var(--euiColorPrimary) !important; - } - - svg { - position: absolute; - width: 12px !important; - height: 12px !important; - right: 5px; - top: 3px; - } -} - -.popoverWrapper { - height: 84px; - width: 182px; - padding: 12px 18px !important; - border: 1px solid var(--euiColorPrimary) !important; - background-color: var(--euiColorLightestShade) !important; - margin-top: -18px; - - :global(.euiPopover__panelArrow) { - &::before, - &::after { - content: none !important; - } - } - - .input { - display: inline-block; - width: 72px; - - input { - height: 36px !important; - border-radius: 4px; - background-color: var(--browserViewTypePassive) !important; - } - } -} - -.inputLabel { - display: inline-block; - width: 70px; - font-size: 14px; - color: var(--euiTextSubduedColor)!important; -} - -.input { - display: inline-block; - width: 72px; - - input { - background-color: var(--euiColorEmptyShade) !important; - } -} diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.spec.tsx new file mode 100644 index 0000000000..591860f190 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.spec.tsx @@ -0,0 +1,154 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { DEFAULT_DELIMITER, SortOrder } from 'uiSrc/constants' +import { resetBrowserTree, setBrowserTreeDelimiter, setBrowserTreeSort } from 'uiSrc/slices/app/context' +import { + cleanup, + clearStoreActions, + fireEvent, + mockedStore, + render, + screen, + act, + waitForEuiPopoverVisible, +} from 'uiSrc/utils/test-utils' + +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import KeyTreeSettings, { Props } from './KeyTreeSettings' + +const mockedProps = mock() +let store: typeof mockedStore +const APPLY_BTN = 'tree-view-apply-btn' +const TREE_SETTINGS_TRIGGER_BTN = 'tree-view-settings-btn' +const SORTING_SELECT = 'tree-view-sorting-select' +const DELIMITER_INPUT = 'tree-view-delimiter-input' +const SORTING_DESC_ITEM = 'tree-view-sorting-item-DESC' + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/services', () => ({ + localStorageService: { + set: jest.fn(), + get: jest.fn(), + }, +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +describe('KeyTreeDelimiter', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('Settings button should be rendered', () => { + render() + + expect(screen.getByTestId(TREE_SETTINGS_TRIGGER_BTN)).toBeInTheDocument() + }) + + it('Delimiter input and Sorting selector should be rendered after click on button', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByTestId(TREE_SETTINGS_TRIGGER_BTN)) + }) + await waitForEuiPopoverVisible() + + expect(screen.getByTestId(DELIMITER_INPUT)).toBeInTheDocument() + expect(screen.getByTestId(SORTING_SELECT)).toBeInTheDocument() + }) + + it('"setBrowserTreeDelimiter" and "setBrowserTreeSort" should be called after Apply change delimiter', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + const value = 'val' + render() + + await act(() => { + fireEvent.click(screen.getByTestId(TREE_SETTINGS_TRIGGER_BTN)) + }) + + await waitForEuiPopoverVisible() + + fireEvent.change(screen.getByTestId(DELIMITER_INPUT), { target: { value } }) + + await act(() => { + fireEvent.click(screen.getByTestId(SORTING_SELECT)) + }) + + await waitForEuiPopoverVisible() + + await act(() => { + fireEvent.click(screen.getByTestId(SORTING_DESC_ITEM)) + }) + + await act(() => { + fireEvent.click(screen.getByTestId(APPLY_BTN)) + }) + + const expectedActions = [ + setBrowserTreeDelimiter(value), + resetBrowserTree(), + setBrowserTreeSort(SortOrder.DESC), + resetBrowserTree(), + ] + + expect(clearStoreActions(store.getActions())).toEqual( + clearStoreActions(expectedActions) + ) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.TREE_VIEW_DELIMITER_CHANGED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + from: DEFAULT_DELIMITER, + to: value, + } + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.TREE_VIEW_KEYS_SORTED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + sorting: SortOrder.DESC, + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('"setBrowserTreeDelimiter" should be called with DEFAULT_DELIMITER after Apply change with empty input', async () => { + const value = '' + render() + + await act(() => { + fireEvent.click(screen.getByTestId(TREE_SETTINGS_TRIGGER_BTN)) + }) + + await waitForEuiPopoverVisible() + + fireEvent.change(screen.getByTestId(DELIMITER_INPUT), { target: { value } }) + + await act(() => { + fireEvent.click(screen.getByTestId(APPLY_BTN)) + }) + + const expectedActions = [ + setBrowserTreeDelimiter(DEFAULT_DELIMITER), + resetBrowserTree(), + ] + + expect(clearStoreActions(store.getActions())).toEqual( + clearStoreActions(expectedActions) + ) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx new file mode 100644 index 0000000000..8febd940db --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx @@ -0,0 +1,174 @@ +import React, { useCallback, useEffect, useState } from 'react' +import cx from 'classnames' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { EuiButton, EuiButtonIcon, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPopover, EuiSuperSelect, EuiText } from '@elastic/eui' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { DEFAULT_DELIMITER, DEFAULT_TREE_SORTING, SortOrder } from 'uiSrc/constants' +import { + appContextDbConfig, + resetBrowserTree, + setBrowserTreeDelimiter, + setBrowserTreeSort, +} from 'uiSrc/slices/app/context' +import { ReactComponent as TreeViewSort } from 'uiSrc/assets/img/browser/treeViewSort.svg' + +import styles from './styles.module.scss' + +export interface Props { + loading: boolean +} +const MAX_DELIMITER_LENGTH = 5 +const sortOptions = [SortOrder.ASC, SortOrder.DESC].map((value) => ({ + value, + inputDisplay: ( + Key name {value} + ), +})) + +const KeyTreeSettings = ({ loading }: Props) => { + const { instanceId = '' } = useParams<{ instanceId: string }>() + const { treeViewDelimiter = '', treeViewSort = DEFAULT_TREE_SORTING } = useSelector(appContextDbConfig) + const [sorting, setSorting] = useState(treeViewSort) + const [delimiter, setDelimiter] = useState(treeViewDelimiter) + + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + }, [treeViewSort]) + + useEffect(() => { + setDelimiter(treeViewDelimiter) + }, [treeViewDelimiter]) + + const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen) + const closePopover = () => { + setIsPopoverOpen(false) + setTimeout(() => { + resetStates() + }, 500) + } + + const resetStates = useCallback(() => { + setSorting(treeViewSort) + setDelimiter(treeViewDelimiter) + }, [treeViewSort, treeViewDelimiter]) + + const button = ( + + ) + + const handleApply = () => { + if (delimiter !== treeViewDelimiter) { + dispatch(setBrowserTreeDelimiter(delimiter || DEFAULT_DELIMITER)) + sendEventTelemetry({ + event: TelemetryEvent.TREE_VIEW_DELIMITER_CHANGED, + eventData: { + databaseId: instanceId, + from: treeViewDelimiter, + to: delimiter || DEFAULT_DELIMITER + } + }) + + dispatch(resetBrowserTree()) + } + + if (sorting !== treeViewSort) { + dispatch(setBrowserTreeSort(sorting)) + + sendEventTelemetry({ + event: TelemetryEvent.TREE_VIEW_KEYS_SORTED, + eventData: { + databaseId: instanceId, + sorting: sorting || DEFAULT_TREE_SORTING, + } + }) + + dispatch(resetBrowserTree()) + } + + setIsPopoverOpen(false) + } + + const onChangeSort = (value: SortOrder) => { + setSorting(value) + } + + return ( +
+ + + + Filters + + +
Delimiter
+ setDelimiter(e.target.value)} + aria-label="Title" + maxLength={MAX_DELIMITER_LENGTH} + data-testid="tree-view-delimiter-input" + /> +
+ +
+ + Sort by +
+ onChangeSort(value)} + data-testid="tree-view-sorting-select" + /> +
+ +
+ + Cancel + + + Apply + +
+
+
+
+
+ ) +} + +export default React.memo(KeyTreeSettings) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/index.ts b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/index.ts new file mode 100644 index 0000000000..f748fe085d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/index.ts @@ -0,0 +1,3 @@ +import KeyTreeSettings from './KeyTreeSettings' + +export default KeyTreeSettings diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss new file mode 100644 index 0000000000..08bfec6e5c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss @@ -0,0 +1,84 @@ +.container { + margin-left: 8px; + margin-top: 1px; + + :global(.euiPopover), .anchorWrapper { + height: 100%; + } +} + +.anchorBtn { + width: 24px; + height: 100% !important; + + svg { + width: 18px !important; + height: 18px !important; + } +} + +.popoverWrapper { + height: 162px; + width: 300px; + padding: 14px 16px !important; + border: none !important; + background-color: var(--euiColorLightestShade) !important; + + :global { + .euiPopover__panelArrow { + &::before, + &::after { + border-bottom-color: var(--euiColorLightestShade) !important; + } + } + .euiFormControlLayout { + height: 26px !important; + width: auto; + } + } + + .input, .select { + width: 188px; + height: 24px !important; + font-size: 12px; + border-radius: 4px; + background-color: var(--browserViewTypePassive) !important; + } +} + +.label { + display: flex; + width: 66px; + font-size: 12px; + color: var(--euiTextSubduedColor)!important; +} + +.title { + font-size: 14px; + font-weight: 500; +} + +.row { + flex-direction: row !important; + align-items: center; + justify-content: space-between; +} + +.sortIcon { + margin-right: 4px; +} + +.selectItem { + min-height: 24px !important; +} + +.footer { + width: 100%; + display: flex; + justify-content: flex-end; + padding-top: 4px; + + button { + margin-left: 8px; + } +} \ No newline at end of file diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/index.ts b/redisinsight/ui/src/pages/browser/components/key-tree/index.ts index 24664563b0..dec23b355f 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/index.ts +++ b/redisinsight/ui/src/pages/browser/components/key-tree/index.ts @@ -1,3 +1,8 @@ import KeyTree from './KeyTree' +import KeyTreeSettings from './KeyTreeSettings' export default KeyTree + +export { + KeyTreeSettings, +} diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-tree/styles.module.scss index 89dc51c251..c07d7ec3e0 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/key-tree/styles.module.scss @@ -2,7 +2,7 @@ @import '@elastic/eui/src/components/table/mixins'; @import '@elastic/eui/src/global_styling/index'; -.page { +.container { height: 100%; overflow: hidden; } @@ -11,67 +11,27 @@ max-width: 372px !important; } +.noKeys { + @include euiScrollBar; + overflow: auto; + + text-align: center; + margin: auto; +} + .content { width: 100%; + display: flex; + flex-direction: column; height: 100%; background-color: var(--euiColorEmptyShade); border-top: 1px solid var(--euiColorLightShade); -} - -.body { - display: flex; - height: 100%; :global(.ReactVirtualized__Table__headerRow) { border: none !important; } } -.resizablePanelLeft { - border-right: 1px solid var(--euiColorLightShade); -} -.resizablePanelLeft, -.resizablePanelRight { - :global(.euiResizablePanel__content) { - padding-right: 0px !important; - } -} - -.resizableButton { - z-index: 1 !important; -} -.resizableButton:not(:hover) { - &::before, &::after { - width: 0 !important; - } -} - -.tree { - @include euiScrollBar; - - border-bottom: none; - position: relative; - padding-top: 6px; - - height: 100%; - - flex: 1; - display: flex; - flex-direction: column; -} - -.treeContent { - height: 100%; - position: relative; - width: 100%; -} - -.list { - height: 100%; - flex: 3; - min-width: 400px; -} - .filter { display: inline-block; width: 100px; diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx index 24102f588f..04a3f9795f 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx @@ -117,6 +117,7 @@ const KeysHeader = (props: Props) => { } const handleRefreshKeys = () => { + dispatch(resetBrowserTree()) dispatch(fetchKeys( { searchMode, diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss b/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss index 7d8902e5ea..73c3a47969 100644 --- a/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss @@ -1,7 +1,7 @@ .container { max-width: 400px; - margin: -10vh auto 0; + margin: auto; text-align: center; } diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx new file mode 100644 index 0000000000..9dd48c0813 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { render, mockedStore, cleanup } from 'uiSrc/utils/test-utils' + +import NoKeysMessage, { Props } from './NoKeysMessage' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('NoKeysMessage', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx new file mode 100644 index 0000000000..844966e77f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx @@ -0,0 +1,65 @@ +import React from 'react' + +import { useSelector } from 'react-redux' +import { SearchMode } from 'uiSrc/slices/interfaces/keys' + +import { + FullScanNoResultsFoundText, + NoResultsFoundText, + NoSelectedIndexText, + ScanNoResultsFoundText, +} from 'uiSrc/constants/texts' +import { keysSelector } from 'uiSrc/slices/browser/keys' +import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' + +import NoKeysFound from '../no-keys-found' + +export interface Props { + total: number + scanned: number + onAddKeyPanel: (value: boolean) => void + onBulkActionsPanel: (value: boolean) => void +} + +const NoKeysMessage = (props:Props) => { + const { + total, + scanned, + onAddKeyPanel, + onBulkActionsPanel, + } = props + + const { selectedIndex } = useSelector(redisearchSelector) + const { isSearched: redisearchIsSearched } = useSelector(redisearchSelector) + const { isSearched: patternIsSearched, isFiltered, searchMode } = useSelector(keysSelector) + + if (searchMode === SearchMode.Redisearch) { + if (!selectedIndex) { + return NoSelectedIndexText + } + + if (total === 0) { + return NoResultsFoundText + } + + if (redisearchIsSearched) { + return scanned < total ? NoResultsFoundText : FullScanNoResultsFoundText + } + } + + if (total === 0) { + return () + } + + if (patternIsSearched) { + return scanned < total ? ScanNoResultsFoundText : FullScanNoResultsFoundText + } + + if (isFiltered && scanned < total) { + return ScanNoResultsFoundText + } + + return NoResultsFoundText +} + +export default NoKeysMessage diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-message/index.ts b/redisinsight/ui/src/pages/browser/components/no-keys-message/index.ts new file mode 100644 index 0000000000..c3107e6843 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/no-keys-message/index.ts @@ -0,0 +1,3 @@ +import NoKeysMessage from './NoKeysMessage' + +export default NoKeysMessage diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx index 8787ada41f..01824f0aa8 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx @@ -22,6 +22,7 @@ import { } from 'uiSrc/slices/browser/redisearch' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { resetBrowserTree } from 'uiSrc/slices/app/context' import styles from './styles.module.scss' const placeholders = { @@ -66,6 +67,8 @@ const SearchKeyList = () => { dispatch(setSearchMatch(match, searchMode)) + viewType === KeyViewType.Tree && dispatch(resetBrowserTree()) + dispatch(fetchKeys( { searchMode, diff --git a/redisinsight/ui/src/slices/app/context.ts b/redisinsight/ui/src/slices/app/context.ts index 1131492d2b..46d7ee2daf 100644 --- a/redisinsight/ui/src/slices/app/context.ts +++ b/redisinsight/ui/src/slices/app/context.ts @@ -1,14 +1,15 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' import { ConfigDBStorageItem } from 'uiSrc/constants/storage' -import { getTreeLeafField, Nullable } from 'uiSrc/utils' +import { Nullable } from 'uiSrc/utils' import { BrowserStorageItem, DEFAULT_DELIMITER, DEFAULT_SLOWLOG_DURATION_UNIT, KeyTypes, DEFAULT_SHOW_HIDDEN_RECOMMENDATIONS, - DurationUnits, + SortOrder, + DEFAULT_TREE_SORTING, } from 'uiSrc/constants' import { localStorageService, setDBConfigStorageField } from 'uiSrc/services' import { RootState } from '../store' @@ -20,6 +21,7 @@ export const initialState: StateAppContext = { lastPage: '', dbConfig: { treeViewDelimiter: DEFAULT_DELIMITER, + treeViewSort: DEFAULT_TREE_SORTING, slowLogDurationUnit: DEFAULT_SLOWLOG_DURATION_UNIT, showHiddenRecommendations: DEFAULT_SHOW_HIDDEN_RECOMMENDATIONS, }, @@ -38,9 +40,8 @@ export const initialState: StateAppContext = { panelSizes: {}, tree: { delimiter: DEFAULT_DELIMITER, - panelSizes: {}, openNodes: {}, - selectedLeaf: {}, + selectedLeaf: null, }, bulkActions: { opened: false, @@ -106,6 +107,10 @@ const appContextSlice = createSlice({ state.dbConfig.treeViewDelimiter = payload setDBConfigStorageField(state.contextInstanceId, BrowserStorageItem.treeViewDelimiter, payload) }, + setBrowserTreeSort: (state, { payload }: PayloadAction) => { + state.dbConfig.treeViewSort = payload + setDBConfigStorageField(state.contextInstanceId, BrowserStorageItem.treeViewSort, payload) + }, setRecommendationsShowHidden: (state, { payload }: { payload: boolean }) => { state.dbConfig.showHiddenRecommendations = payload setDBConfigStorageField(state.contextInstanceId, BrowserStorageItem.showHiddenRecommendations, payload) @@ -137,38 +142,9 @@ const appContextSlice = createSlice({ setBrowserPanelSizes: (state, { payload }: { payload: any }) => { state.browser.panelSizes = payload }, - setBrowserTreeSelectedLeaf: (state, { payload }: { payload: any }) => { - state.browser.tree.selectedLeaf = payload - }, - updateBrowserTreeSelectedLeaf: (state, { payload }) => { - const { selectedLeaf, delimiter } = state.browser.tree - const [[selectedLeafField = '', keys = {}]] = Object.entries(selectedLeaf) - const [pattern] = selectedLeafField.split(getTreeLeafField(delimiter)) - - if (payload.key in keys) { - const isFitNewKey = payload.newKey?.startsWith?.(pattern) - && (pattern.split(delimiter)?.length === payload.newKey.split(delimiter)?.length) - - if (!isFitNewKey) { - delete keys[payload.key] - return - } - - keys[payload.newKey] = { - ...keys[payload.key], - name: payload.newKey - } - delete keys[payload.key] - } - - state.browser.tree.selectedLeaf[selectedLeafField] = keys - }, setBrowserTreeNodesOpen: (state, { payload }: { payload: { [key: string]: boolean; } }) => { state.browser.tree.openNodes = payload }, - setBrowserTreePanelSizes: (state, { payload }: { payload: any }) => { - state.browser.tree.panelSizes = payload - }, setWorkbenchScript: (state, { payload }: { payload: string }) => { state.workbench.script = payload }, @@ -197,7 +173,7 @@ const appContextSlice = createSlice({ localStorageService.set(BrowserStorageItem.isEnablementAreaMinimized, payload) }, resetBrowserTree: (state) => { - state.browser.tree.selectedLeaf = {} + state.browser.tree.selectedLeaf = null state.browser.tree.openNodes = {} }, setPubSubFieldsContext: (state, { payload }: { payload: { channel: string, message: string } }) => { @@ -240,12 +216,9 @@ export const { setBrowserRedisearchScrollPosition, setBrowserIsNotRendered, setBrowserPanelSizes, - setBrowserTreeSelectedLeaf, setBrowserTreeNodesOpen, setBrowserTreeDelimiter, - updateBrowserTreeSelectedLeaf, resetBrowserTree, - setBrowserTreePanelSizes, setWorkbenchScript, setWorkbenchVerticalPanelSizes, setLastPageContext, @@ -261,6 +234,7 @@ export const { setDbIndexState, setRecommendationsShowHidden, setLastTriggeredFunctionsPage, + setBrowserTreeSort, } = appContextSlice.actions // Selectors diff --git a/redisinsight/ui/src/slices/browser/keys.ts b/redisinsight/ui/src/slices/browser/keys.ts index 6044119189..30d9eb16f1 100644 --- a/redisinsight/ui/src/slices/browser/keys.ts +++ b/redisinsight/ui/src/slices/browser/keys.ts @@ -1023,6 +1023,39 @@ export function fetchKeysMetadata( } } +// Asynchronous thunk action +export function fetchKeysMetadataTree( + keys: RedisString[], + filter: Nullable, + signal?: AbortSignal, + onSuccessAction?: (data: GetKeyInfoResponse[]) => void, + onFailAction?: () => void +) { + return async (_dispatch: AppDispatch, stateInit: () => RootState) => { + try { + const state = stateInit() + const { data } = await apiService.post( + getUrl( + state.connections.instances?.connectedInstance?.id, + ApiEndpoints.KEYS_METADATA + ), + { keys: keys.map(([,nameBuffer]) => nameBuffer), type: filter || undefined }, + { params: { encoding: state.app.info.encoding }, signal } + ) + + const newData = data.map((key, i) => ({ ...key, path: keys[i][0] })) + + onSuccessAction?.(newData) + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + onFailAction?.() + console.error(error) + } + } + } +} + export function fetchPatternHistoryAction( onSuccess?: () => void, onFailed?: () => void, diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index e99ce6c1f3..40fb560410 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -1,8 +1,7 @@ import { AxiosError } from 'axios' import { RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' import { Nullable } from 'uiSrc/utils' -import { DurationUnits, FeatureFlags, ICommands } from 'uiSrc/constants' -import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { DurationUnits, FeatureFlags, ICommands, SortOrder } from 'uiSrc/constants' import { GetServerInfoResponse } from 'apiSrc/modules/server/dto/server.dto' import { RedisString as RedisStringAPI } from 'apiSrc/common/constants/redis-string' @@ -50,6 +49,7 @@ export interface StateAppContext { lastPage: string dbConfig: { treeViewDelimiter: string + treeViewSort: SortOrder slowLogDurationUnit: DurationUnits showHiddenRecommendations: boolean } @@ -70,17 +70,10 @@ export interface StateAppContext { } tree: { delimiter: string - panelSizes: { - [key: string]: number - } openNodes: { [key: string]: boolean } - selectedLeaf: { - [key: string]: { - [key: string]: IKeyPropTypes - } - } + selectedLeaf: Nullable } bulkActions: { opened: boolean diff --git a/redisinsight/ui/src/slices/tests/app/context.spec.ts b/redisinsight/ui/src/slices/tests/app/context.spec.ts index 18a2ac60a8..75e05b99f6 100644 --- a/redisinsight/ui/src/slices/tests/app/context.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/context.spec.ts @@ -27,11 +27,8 @@ import reducer, { setWorkbenchEAItemScrollTop, resetWorkbenchEASearch, setBrowserTreeNodesOpen, - setBrowserTreePanelSizes, resetBrowserTree, appContextBrowserTree, - setBrowserTreeSelectedLeaf, - updateBrowserTreeSelectedLeaf, setBrowserTreeDelimiter, setBrowserIsNotRendered, setBrowserRedisearchScrollPosition, @@ -477,72 +474,6 @@ describe('slices', () => { }) }) - describe('setBrowserTreeSelectedLeaf', () => { - it('should properly set selected keys in the tree', () => { - // Arrange - const selectedLeaf = { - [getTreeLeafField(DEFAULT_DELIMITER)]: { - test: { - name: 'test', - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - } - } - } - const prevState = { - ...initialState, - browser: { - ...initialState.browser, - tree: { - ...initialState.browser.tree, - selectedLeaf - } - }, - } - - const state = { - ...initialState.browser.tree, - selectedLeaf - } - - // Act - const nextState = reducer(prevState, setBrowserTreeSelectedLeaf(selectedLeaf)) - - // Assert - const rootState = Object.assign(initialStateDefault, { - app: { context: nextState }, - }) - - expect(appContextBrowserTree(rootState)).toEqual(state) - }) - }) - - describe('setBrowserTreePanelSizes', () => { - it('should properly set browser tree panel widths', () => { - // Arrange - const panelSizes = { - first: 50, - second: 400 - } - const state = { - ...initialState.browser.tree, - panelSizes - } - - // Act - const nextState = reducer(initialState, setBrowserTreePanelSizes(panelSizes)) - - // Assert - const rootState = Object.assign(initialStateDefault, { - app: { context: nextState }, - }) - - expect(appContextBrowserTree(rootState)).toEqual(state) - }) - }) - describe('setBrowserIsNotRendered', () => { it('should properly set browser is not rendered value', () => { // Arrange @@ -671,24 +602,14 @@ describe('slices', () => { openNodes: { test: true }, - selectedLeaf: { - [getTreeLeafField(DEFAULT_DELIMITER)]: { - test: { - name: 'test', - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - } - } - } + selectedLeaf: 'test', } }, } const state = { ...initialState.browser.tree, openNodes: {}, - selectedLeaf: {} + selectedLeaf: null } // Act @@ -703,119 +624,6 @@ describe('slices', () => { }) }) - describe('updateBrowserTreeSelectedLeaf', () => { - it('should properly update selected leaf and add a new fitted key', () => { - const payload = { - key: 'test', - newKey: 'test2' - } - // Arrange - const prevState = { - ...initialState, - browser: { - ...initialState.browser, - tree: { - ...initialState.browser.tree, - selectedLeaf: { - [getTreeLeafField(DEFAULT_DELIMITER)]: { - [payload.key]: { - name: payload.key, - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - } - } - } - } - }, - } - const state = { - ...initialState.browser.tree, - openNodes: {}, - selectedLeaf: { - [getTreeLeafField(DEFAULT_DELIMITER)]: { - [payload.newKey]: { - name: payload.newKey, - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - } } - } - } - - // Act - const nextState = reducer(prevState, updateBrowserTreeSelectedLeaf(payload)) - - // Assert - const rootState = Object.assign(initialStateDefault, { - app: { context: nextState }, - }) - - expect(appContextBrowserTree(rootState)).toEqual(state) - }) - it("should properly update selected leaf and remove old key (new key does't fit)", () => { - const payload = { - key: 'test', - newKey: 'test:2' - } - // Arrange - const prevState = { - ...initialState, - browser: { - ...initialState.browser, - tree: { - ...initialState.browser.tree, - selectedLeaf: { - [getTreeLeafField(DEFAULT_DELIMITER)]: { - [payload.key]: { - name: payload.key, - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - }, - test2: { - name: 'test2', - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - } - } - } - } - }, - } - const state = { - ...initialState.browser.tree, - openNodes: {}, - selectedLeaf: { - [getTreeLeafField(DEFAULT_DELIMITER)]: { - test2: { - name: 'test2', - type: KeyTypes.Hash, - ttl: 123, - size: 123, - length: 321 - }, - } - } - } - - // Act - const nextState = reducer(prevState, updateBrowserTreeSelectedLeaf(payload)) - - // Assert - const rootState = Object.assign(initialStateDefault, { - app: { context: nextState }, - }) - - expect(appContextBrowserTree(rootState)).toEqual(state) - }) - }) - describe('updateKeyDetailsSizes', () => { it('should properly update sizes', () => { // Arrange diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index f9a87f51c9..ae3a7d6134 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -162,6 +162,7 @@ export enum TelemetryEvent { TREE_VIEW_KEYS_SCANNED_WITH_FILTER_ENABLED = 'TREE_VIEW_KEYS_SCANNED_WITH_FILTER_ENABLED', TREE_VIEW_KEYS_ADDITIONALLY_SCANNED = 'TREE_VIEW_KEYS_ADDITIONALLY_SCANNED', TREE_VIEW_DELIMITER_CHANGED = 'TREE_VIEW_DELIMITER_CHANGED', + TREE_VIEW_KEYS_SORTED = 'TREE_VIEW_KEYS_SORTED', TREE_VIEW_KEY_ADDED = 'TREE_VIEW_KEY_ADDED', TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED = 'TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED', TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED = 'TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED', diff --git a/redisinsight/ui/src/utils/tests/tree.spec.ts b/redisinsight/ui/src/utils/tests/tree.spec.ts index 9eb64953d9..4c04963a49 100644 --- a/redisinsight/ui/src/utils/tests/tree.spec.ts +++ b/redisinsight/ui/src/utils/tests/tree.spec.ts @@ -1,5 +1,4 @@ -import { findTreeNode, getTreeLeafField } from 'uiSrc/utils' -import nodes from './nodes.json' +import { getTreeLeafField } from 'uiSrc/utils' const getTreeLeafFieldTests: any[] = [ [':', 'keys:keys'], @@ -19,23 +18,3 @@ describe('getTreeLeafField', () => { expect(result).toBe(expected) }) }) - -const findTreeNodeTests: any[] = [ - ['hash2:keys:keys:', 'id', null], - ['hash2:keys:keys:', 'fullName', nodes[1]?.children[0]], - ['hash:string:', 'fullName', nodes[0]?.children[1]], - ['hash:string:keys:keys:', 'fullName', nodes[0]?.children[1]?.children[0]], - ['0.g9y9ox4nau', 'id', nodes[0]?.children[0]], - ['hash2:keys:keys:', 'id', null], - ['uoeuoeuoe', 'id', null], - ['uoeuoeuoe', 'fullName', null], - ['hash2:', 'fullName', nodes[1]], -] - -describe('findTreeNode', () => { - it.each(findTreeNodeTests)('for input: %s (reply), should be output: %s', - (reply, key, expected) => { - const result = findTreeNode(nodes, reply, key) - expect(result).toBe(expected) - }) -}) diff --git a/redisinsight/ui/src/utils/tree.ts b/redisinsight/ui/src/utils/tree.ts index 817cd5d600..fc235737a7 100644 --- a/redisinsight/ui/src/utils/tree.ts +++ b/redisinsight/ui/src/utils/tree.ts @@ -1,26 +1 @@ -import { TreeNode } from 'uiSrc/components/virtual-tree' -import { Nullable } from './types' - export const getTreeLeafField = (delimiter = '') => `keys${delimiter}keys` - -export const findTreeNode = ( - data: TreeNode[], - value: string, - key = 'id', - tempObj: { found?: TreeNode } = {}, -): Nullable => { - if (value && data) { - // eslint-disable-next-line sonarjs/no-ignored-return - data.find((node) => { - if (node[key] === value) { - tempObj.found = node - return node - } - return findTreeNode(node.children, value, key, tempObj) - }) - if (tempObj.found) { - return tempObj.found - } - } - return null -} From 0408d83edfa906658e378eb7d25b03bbfa66362d Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Fri, 20 Oct 2023 18:54:13 +0200 Subject: [PATCH 02/96] #RI-4828 - Re-work the Tree view --- package.json | 8 +- patches/react-vtree+3.0.0-beta.3.patch | 29 +- .../virtual-tree/components/Node/Node.tsx | 356 ------------------ redisinsight/ui/src/constants/texts.tsx | 17 +- .../ui/src/helpers/constructKeysToTree.ts | 1 - .../tests/constructKeysToTreeMockResult.ts | 10 - .../browser/components/key-list/KeyList.tsx | 207 ++-------- .../components/key-list/styles.module.scss | 8 +- .../key-row-name/KeyRowName.spec.tsx | 29 ++ .../components/key-row-name/KeyRowName.tsx | 52 +++ .../browser/components/key-row-name/index.ts | 3 + .../key-row-name/styles.module.scss | 15 + .../key-row-size/KeyRowSize.spec.tsx | 49 +++ .../components/key-row-size/KeyRowSize.tsx | 135 +++++++ .../browser/components/key-row-size/index.ts | 3 + .../key-row-size/styles.module.scss | 10 + .../components/key-row-ttl/KeyRowTTL.spec.tsx | 49 +++ .../components/key-row-ttl/KeyRowTTL.tsx | 84 +++++ .../browser/components/key-row-ttl/index.ts | 3 + .../components/key-row-ttl/styles.module.scss | 10 + .../key-row-type/KeyRowType.spec.tsx | 37 ++ .../components/key-row-type/KeyRowType.tsx | 37 ++ .../browser/components/key-row-type/index.ts | 3 + .../key-row-type/styles.module.scss | 11 + .../components/key-tree/KeyTree.spec.tsx | 98 ++++- .../browser/components/key-tree/KeyTree.tsx | 6 +- .../no-keys-message/NoKeysMessage.spec.tsx | 157 +++++++- .../no-keys-message/NoKeysMessage.tsx | 3 +- .../virtual-tree/VirtualTree.spec.tsx | 0 .../components/virtual-tree/VirtualTree.tsx | 9 +- .../components/Node/Node.spec.tsx | 67 +++- .../virtual-tree/components/Node/Node.tsx | 198 ++++++++++ .../virtual-tree/components/Node/index.ts | 0 .../components/Node/styles.module.scss | 19 +- .../browser}/components/virtual-tree/index.ts | 0 .../components/virtual-tree/interfaces.ts | 2 - .../virtual-tree/styles.module.scss | 0 yarn.lock | 89 ++++- 38 files changed, 1207 insertions(+), 607 deletions(-) delete mode 100644 redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-name/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-name/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-size/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-size/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-ttl/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-ttl/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-type/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/key-row-type/styles.module.scss rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/VirtualTree.spec.tsx (100%) rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/VirtualTree.tsx (98%) rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/components/Node/Node.spec.tsx (58%) create mode 100644 redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/components/Node/index.ts (100%) rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/components/Node/styles.module.scss (88%) rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/index.ts (100%) rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/interfaces.ts (97%) rename redisinsight/ui/src/{ => pages/browser}/components/virtual-tree/styles.module.scss (100%) diff --git a/package.json b/package.json index 2ea65190c0..6a764dddbd 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "package:mac": "yarn build:prod && electron-builder build --mac -p never", "package:mac:arm": "yarn build:prod && electron-builder build --mac --arm64 -p never", "package:linux": "yarn build:prod && electron-builder build --linux -p never", - "postinstall": "skip-postinstall || (electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.renderer.dev.dll.ts && opencollective-postinstall && yarn-deduplicate yarn.lock)", + "postinstall": "patch-package && skip-postinstall || (electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.renderer.dev.dll.ts && opencollective-postinstall && yarn-deduplicate yarn.lock)", "start": "ts-node ./scripts/check-port-in-use.js && yarn start:renderer", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.renderer.dev.ts", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.preload.dev.ts", @@ -41,8 +41,8 @@ "start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "start:web:public": "cross-env PUBLIC_DEV=true NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "test": "jest ./redisinsight/ui -w 1", - "test:watch": "jest ./redisinsight/ui --watch -w 1", - "test:cov": "jest ./redisinsight/ui --coverage --no-cache --forceExit -w 3", + "test:watch": "jest ./redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx --watch -w 1", + "test:cov": "jest ./redisinsight/ui --silent --coverage --no-cache --forceExit -w 3", "test:cov:unit": "jest ./redisinsight/ui --group=-component --coverage -w 1", "test:cov:component": "jest ./redisinsight/ui --group=component --coverage -w 1", "type-check:ui": "tsc --project redisinsight/ui --noEmit" @@ -201,6 +201,8 @@ "msw": "^1.3.2", "node-sass": "^8.0.0", "opencollective-postinstall": "^2.0.3", + "patch-package": "^8.0.0", + "postinstall-postinstall": "^2.1.0", "react-hot-loader": "^4.13.0", "react-refresh": "^0.9.0", "redux-mock-store": "^1.5.4", diff --git a/patches/react-vtree+3.0.0-beta.3.patch b/patches/react-vtree+3.0.0-beta.3.patch index 7629fc04cc..cf1b00df37 100644 --- a/patches/react-vtree+3.0.0-beta.3.patch +++ b/patches/react-vtree+3.0.0-beta.3.patch @@ -1,12 +1,25 @@ +diff --git a/node_modules/react-vtree/dist/cjs/Tree.js b/node_modules/react-vtree/dist/cjs/Tree.js +index c46ce3e..879f0a6 100644 +--- a/node_modules/react-vtree/dist/cjs/Tree.js ++++ b/node_modules/react-vtree/dist/cjs/Tree.js +@@ -33,6 +33,7 @@ var Row = function Row(_ref) { + return /*#__PURE__*/_react.default.createElement(Node, Object.assign({ + isScrolling: isScrolling, + style: style, ++ index: index, + treeData: treeData + }, data)); + }; diff --git a/node_modules/react-vtree/dist/es/Tree.d.ts b/node_modules/react-vtree/dist/es/Tree.d.ts -index 5e7f57e..15c09d4 100644 +index 5e7f57e..b216b36 100644 --- a/node_modules/react-vtree/dist/es/Tree.d.ts +++ b/node_modules/react-vtree/dist/es/Tree.d.ts -@@ -24,6 +24,7 @@ export declare type NodePublicState = Readonly<{ +@@ -24,6 +24,8 @@ export declare type NodePublicState = Readonly<{ data: TData; setOpen: (state: boolean) => Promise; }> & { + index: number; ++ style: object; isOpen: boolean; }; export declare type NodeRecord> = Readonly<{ @@ -22,3 +35,15 @@ index 2b1c7c0..b22e873 100644 treeData: treeData }, data)); }; +diff --git a/node_modules/react-vtree/dist/lib/Tree.js b/node_modules/react-vtree/dist/lib/Tree.js +index fb824bd..6feba4e 100644 +--- a/node_modules/react-vtree/dist/lib/Tree.js ++++ b/node_modules/react-vtree/dist/lib/Tree.js +@@ -17,6 +17,7 @@ export const Row = ({ + return /*#__PURE__*/React.createElement(Node, Object.assign({ + isScrolling: isScrolling, + style: style, ++ index: index, + treeData: treeData + }, data)); + }; diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx deleted file mode 100644 index 595e4af36c..0000000000 --- a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { NodePublicState } from 'react-vtree/dist/es/Tree' -import cx from 'classnames' -import { isUndefined } from 'lodash' -import { - EuiIcon, - EuiToolTip, - keys as ElasticKeys, - EuiButtonIcon, - EuiLoadingContent, - EuiText, - EuiTextColor, - EuiPopover, - EuiSpacer, - EuiButton, -} from '@elastic/eui' - -import GroupBadge from 'uiSrc/components/group-badge' -import { - Maybe, - formatBytes, - formatLongName, - replaceSpaces, - truncateNumberToDuration, - truncateNumberToFirstUnit, - truncateTTLToSeconds, -} from 'uiSrc/utils' -import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' -import { TreeData } from '../../interfaces' -import styles from './styles.module.scss' - -// Node component receives all the data we created in the `treeWalker` + -// internal openness state (`isOpen`), function to change internal openness -// `style` parameter that should be added to the root div. -const Node = ({ - data, - isOpen, - index, - style, - setOpen, -}: NodePublicState) => { - const { - id: nodeId, - isLeaf, - keyCount, - nestingLevel, - fullName, - nameBuffer, - path, - type, - ttl, - size, - deleting, - shortName, - nameString, - keyApproximate, - isSelected, - getMetadata, - onDelete, - onDeleteClicked, - updateStatusOpen, - updateStatusSelected, - } = data - - const [deletePopoverId, setDeletePopoverId] = useState>(undefined) - - useEffect(() => { - if (!size && isLeaf && nameBuffer) { - getMetadata(nameBuffer, path) - } - }, []) - - useEffect(() => { - if (isSelected && nameBuffer) { - updateStatusSelected?.(nameBuffer) - } - }, [isSelected]) - - const handleClick = () => { - if (isLeaf && !isSelected) { - updateStatusSelected?.(nameBuffer) - } - - updateStatusOpen?.(fullName, !isOpen) - !isLeaf && setOpen(!isOpen) - } - - const handleKeyDown = ({ key }: React.KeyboardEvent) => { - if (key === ElasticKeys.SPACE) { - handleClick() - } - } - - const handleDelete = () => { - onDelete(nameBuffer) - setDeletePopoverId(undefined) - } - - const handleDeletePopoverOpen = (index: Maybe, type: KeyTypes | ModulesKeyTypes) => { - if (index !== deletePopoverId) { - onDeleteClicked(type) - } - setDeletePopoverId(index !== deletePopoverId ? index : undefined) - } - - const Folder = () => ( - <> -
- - - {nameString} -
-
-
- {keyApproximate ? `${keyApproximate < 1 ? '<1' : Math.round(keyApproximate)}%` : '' } -
-
{keyCount ?? ''}
-
- - ) - - const Leaf = () => ( - <> - - - - - - ) - - const LeafType = () => ( - <> - {!type && } - {!!type &&
} - - ) - - const LeafName = () => { - // Better to cut the long string, because it could affect virtual scroll performance - const nameContent = replaceSpaces(shortName?.substring?.(0, 200)) - const nameTooltipContent = formatLongName(nameString) - - return ( -
- -
- - <>{nameContent} - -
-
-
- ) - } - - const LeafTTL = () => { - if (isUndefined(ttl)) { - return - } - if (ttl === -1) { - return ( - - No limit - - ) - } - return ( - -
- - {`${truncateTTLToSeconds(ttl)} s`} -
- {`(${truncateNumberToDuration(ttl)})`} - - )} - > - <>{truncateNumberToFirstUnit(ttl)} -
-
-
- ) - } - - const LeafSize = () => { - if (isUndefined(size)) { - return - } - - if (!size) { - return ( - - - - - ) - } - return ( - <> - -
- - {formatBytes(size, 3)} - - )} - > - <>{formatBytes(size, 0)} - -
-
- setDeletePopoverId(undefined)} - panelPaddingSize="l" - panelClassName={styles.deletePopover} - button={( - handleDeletePopoverOpen(nodeId, type)} - aria-label="Delete Key" - data-testid={`delete-key-btn-${nameString}`} - /> - )} - onClick={(e) => e.stopPropagation()} - > - <> - -

{formatLongName(nameString)}

- will be deleted. -
- - - Delete - - -
- - ) - } - - const Node = ( -
{}} - data-testid={`node-item_${fullName}`} - > - {!isLeaf && } - {isLeaf && } -
- ) - - const tooltipContent = ( - <> - {`${fullName}*`} -
- {`${keyCount} key(s) (${Math.round(keyApproximate * 100) / 100}%)`} - - ) - - return ( -
- {isLeaf && Node} - {!isLeaf && ( - - {Node} - - )} -
- ) -} - -export default Node diff --git a/redisinsight/ui/src/constants/texts.tsx b/redisinsight/ui/src/constants/texts.tsx index 1ce0beeca2..f8881ed31a 100644 --- a/redisinsight/ui/src/constants/texts.tsx +++ b/redisinsight/ui/src/constants/texts.tsx @@ -1,8 +1,19 @@ import React from 'react' import { EuiText, EuiSpacer } from '@elastic/eui' -export const NoResultsFoundText = (No results found.) -export const NoSelectedIndexText = (Select an index and enter a query to search per values of keys.) +export const NoResultsFoundText = ( + + No results found. + +) +export const NoSelectedIndexText = ( + + Select an index and enter a query to search per values of keys. + +) export const FullScanNoResultsFoundText = ( <> @@ -19,7 +30,7 @@ export const FullScanNoResultsFoundText = ( ) export const ScanNoResultsFoundText = ( <> - No results found. + No results found.
Use "Scan more" button to proceed or filter per exact Key Name to scan more efficiently. diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index 18e10c6b4d..d75559b94b 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -97,7 +97,6 @@ export const constructKeysToTree = (props: Props): any[] => { node.children = [] node.nameString = name.slice(0, -keysSymbol.length) node.nameBuffer = tree[key]?.name - node.shortName = node.nameString?.slice(previousKey.length) } node.path = path diff --git a/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts b/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts index 53db673148..0bce8a266c 100644 --- a/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts +++ b/redisinsight/ui/src/helpers/tests/constructKeysToTreeMockResult.ts @@ -9,7 +9,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'empty::test', isLeaf: true, children: [], - shortName: 'test', path: '0.0.0', fullName: 'empty::empty::testkeys:keys:', } @@ -35,7 +34,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys:1:1', isLeaf: true, children: [], - shortName: '1', path: '1.0.0', fullName: 'keys:1:keys:1:1keys:keys:', }, @@ -43,7 +41,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys:1:2', isLeaf: true, children: [], - shortName: '2', path: '1.0.1', fullName: 'keys:1:keys:1:2keys:keys:', } @@ -57,7 +54,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys:1', isLeaf: true, children: [], - shortName: '1', path: '1.1', fullName: 'keys:keys:1keys:keys:', }, @@ -65,7 +61,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys:2', isLeaf: true, children: [], - shortName: '2', path: '1.2', fullName: 'keys:keys:2keys:keys:', }, @@ -73,7 +68,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys:3', isLeaf: true, children: [], - shortName: '3', path: '1.3', fullName: 'keys:keys:3keys:keys:', } @@ -87,7 +81,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys1', isLeaf: true, children: [], - shortName: 'keys1', path: '2', fullName: 'keys1keys:keys:', }, @@ -95,7 +88,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'keys2', isLeaf: true, children: [], - shortName: 'keys2', path: '3', fullName: 'keys2keys:keys:', }, @@ -103,7 +95,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'test1', isLeaf: true, children: [], - shortName: 'test1', path: '4', fullName: 'test1keys:keys:', }, @@ -111,7 +102,6 @@ export const constructKeysToTreeMockResult = [ nameString: 'test2', isLeaf: true, children: [], - shortName: 'test2', path: '5', fullName: 'test2keys:keys:', } diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx index f238dfcac5..6df981124a 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -4,34 +4,13 @@ import cx from 'classnames' import { useParams } from 'react-router-dom' import { debounce, findIndex, isUndefined, reject } from 'lodash' -import { - EuiText, - EuiToolTip, - EuiTextColor, - EuiLoadingContent, - EuiPopover, - EuiButton, - EuiButtonIcon, EuiSpacer, -} from '@elastic/eui' import { CellMeasurerCache } from 'react-virtualized' import { - formatBytes, - truncateNumberToDuration, - truncateNumberToFirstUnit, - truncateTTLToSeconds, - replaceSpaces, - formatLongName, bufferToString, bufferFormatRangeItems, Nullable, Maybe, } from 'uiSrc/utils' -import { - NoResultsFoundText, - FullScanNoResultsFoundText, - ScanNoResultsFoundText, - NoSelectedIndexText, -} from 'uiSrc/constants/texts' import { deleteKeyAction, fetchKeysMetadata, @@ -46,7 +25,6 @@ import { setBrowserIsNotRendered, setBrowserRedisearchScrollPosition, } from 'uiSrc/slices/app/context' -import { GroupBadge } from 'uiSrc/components' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import { KeysStoreData, SearchMode } from 'uiSrc/slices/interfaces/keys' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' @@ -54,8 +32,11 @@ import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' import { KeyTypes, ModulesKeyTypes, TableCellAlignment, TableCellTextAlignment } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import KeyRowTTL from 'uiSrc/pages/browser/components/key-row-ttl' +import KeyRowSize from 'uiSrc/pages/browser/components/key-row-size' +import KeyRowName from 'uiSrc/pages/browser/components/key-row-name' +import KeyRowType from 'uiSrc/pages/browser/components/key-row-type' import { GetKeyInfoResponse } from 'apiSrc/modules/browser/dto' @@ -101,9 +82,8 @@ const KeyList = forwardRef((props: Props, ref) => { const { instanceId = '' } = useParams<{ instanceId: string }>() const selectedKey = useSelector(selectedKeySelector) - const { total, nextCursor, previousResultCount } = useSelector(keysDataSelector) - const { isSearched, isFiltered, viewType, searchMode, deleting } = useSelector(keysSelector) - const { selectedIndex } = useSelector(redisearchSelector) + const { nextCursor, previousResultCount } = useSelector(keysDataSelector) + const { isSearched, isFiltered, searchMode, deleting } = useSelector(keysSelector) const { keyList: { isNotRendered: isNotRenderedContext } } = useSelector(appContextBrowser) const [, rerender] = useState({}) @@ -301,10 +281,8 @@ const KeyList = forwardRef((props: Props, ref) => { label: 'Type', absoluteWidth: 'auto', minWidth: 126, - render: (cellData: any, { nameString: name }: any) => ( - isUndefined(cellData) - ? - : + render: (cellData: any, { nameString }: any) => ( + ) }, { @@ -312,36 +290,9 @@ const KeyList = forwardRef((props: Props, ref) => { label: 'Key', minWidth: 94, truncateText: true, - render: (cellData: string) => { - if (isUndefined(cellData)) { - return ( - - ) - } - // Better to cut the long string, because it could affect virtual scroll performance - const name = cellData || '' - const cellContent = replaceSpaces(name?.substring(0, 200)) - const tooltipContent = formatLongName(name) - return ( - -
- - <>{cellContent} - -
-
- ) - } + render: (cellData: string) => ( + + ) }, { id: 'ttl', @@ -350,48 +301,9 @@ const KeyList = forwardRef((props: Props, ref) => { minWidth: 86, truncateText: true, alignment: TableCellAlignment.Right, - render: (cellData: number, { nameString: name }: IKeyPropTypes, _expanded, rowIndex) => { - if (isUndefined(cellData)) { - return - } - if (cellData === -1) { - return ( - - No limit - - ) - } - return ( - -
- - {`${truncateTTLToSeconds(cellData)} s`} -
- {`(${truncateNumberToDuration(cellData)})`} - - )} - > - <>{truncateNumberToFirstUnit(cellData)} -
-
-
- ) - }, + render: (cellData: number, { nameString }: IKeyPropTypes, _expanded, rowIndex) => ( + + ) }, { id: 'size', @@ -402,84 +314,23 @@ const KeyList = forwardRef((props: Props, ref) => { textAlignment: TableCellTextAlignment.Right, render: ( cellData: number, - { nameString: name, type, name: bufferName }: IKeyPropTypes, + { nameString, type, name: bufferName }: IKeyPropTypes, _expanded, rowIndex - ) => { - if (isUndefined(cellData)) { - return - } - - if (!cellData) { - return ( - - - - - ) - } - return ( - <> - -
- - {formatBytes(cellData, 3)} - - )} - > - <>{formatBytes(cellData, 0)} - -
-
- setDeletePopoverIndex(undefined)} - panelPaddingSize="l" - panelClassName={styles.deletePopover} - button={( - handleDeletePopoverOpen(rowIndex, type)} - aria-label="Delete Key" - data-testid={`delete-key-btn-${name}`} - /> - )} - onClick={(e) => e.stopPropagation()} - > - <> - -

{formatLongName(name)}

- will be deleted. -
- - handleRemoveKey(bufferName)} - data-testid="submit-delete-key" - > - Delete - - -
- - ) - } + ) => ( + + ) }, ] diff --git a/redisinsight/ui/src/pages/browser/components/key-list/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-list/styles.module.scss index 9b75dab2e9..ddefddc587 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/key-list/styles.module.scss @@ -27,19 +27,19 @@ :global { .ReactVirtualized__Table__row { .ReactVirtualized__Table__rowColumn { - .moveOnHover { + .moveOnHoverKey { transition: transform ease 0.3s; &.hide { transform: translateX(-8px) } } - .showOnHover { + .showOnHoverKey { display: none; &.show { display: block !important; }} } &:hover { .ReactVirtualized__Table__rowColumn { - .moveOnHover { transform: translateX(-8px) } - .showOnHover { display: block; } + .moveOnHoverKey { transform: translateX(-8px) } + .showOnHoverKey { display: block !important; } } } } diff --git a/redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.spec.tsx new file mode 100644 index 0000000000..8ee2f086ea --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' + +import KeyRowName, { Props } from './KeyRowName' + +const mockedProps = mock() + +const loadingTestId = 'name-loading' + +describe('KeyRowName', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Loading if no nameString', () => { + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId)).toBeInTheDocument() + }) + + it('content should be no more than 200 symbols', () => { + const longName = Array.from({ length: 250 }, () => '1').join('') + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId)).not.toBeInTheDocument() + expect(queryByTestId(`key-${longName}`)).toHaveTextContent(longName.slice(0, 200)) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.tsx b/redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.tsx new file mode 100644 index 0000000000..5dfc1df372 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-name/KeyRowName.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { + EuiLoadingContent, + EuiText, + EuiToolTip, +} from '@elastic/eui' +import { isUndefined } from 'lodash' + +import { Maybe, formatLongName, replaceSpaces } from 'uiSrc/utils' +import styles from './styles.module.scss' + +export interface Props { + nameString: Maybe +} + +const KeyRowName = (props: Props) => { + const { nameString } = props + + if (isUndefined(nameString)) { + return ( + + ) + } + + // Better to cut the long string, because it could affect virtual scroll performance + const nameContent = replaceSpaces(nameString?.substring?.(0, 200)) + const nameTooltipContent = formatLongName(nameString) + + return ( +
+ +
+ + <>{nameContent} + +
+
+
+ ) +} + +export default KeyRowName diff --git a/redisinsight/ui/src/pages/browser/components/key-row-name/index.ts b/redisinsight/ui/src/pages/browser/components/key-row-name/index.ts new file mode 100644 index 0000000000..03ecaaf22a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-name/index.ts @@ -0,0 +1,3 @@ +import KeyRowName from './KeyRowName' + +export default KeyRowName diff --git a/redisinsight/ui/src/pages/browser/components/key-row-name/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-row-name/styles.module.scss new file mode 100644 index 0000000000..8157e074f4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-name/styles.module.scss @@ -0,0 +1,15 @@ +.keyInfoLoading { + width: 70%; + margin-top: 7px; +} + +.keyName { + flex-grow: 1; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + + :global(.euiTextColor) { + max-width: 100%; + } +} diff --git a/redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.spec.tsx new file mode 100644 index 0000000000..69e2da224c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' + +import { render } from 'uiSrc/utils/test-utils' +import { formatBytes } from 'uiSrc/utils' +import KeyRowSize, { Props } from './KeyRowSize' + +const mockedProps = mock() +const loadingTestId = 'size-loading_' +const nameString = 'name' + +describe('KeyRowSize', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Loading if no size', () => { + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).toBeInTheDocument() + }) + + it('should render "-" if size is empty', () => { + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).not.toBeInTheDocument() + expect(queryByTestId(`size-${nameString}`)).toHaveTextContent('-') + }) + + it('should render formatted size', () => { + const size = 123123123 + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).not.toBeInTheDocument() + expect(queryByTestId(`size-${nameString}`)).toHaveTextContent(formatBytes(size, 0) as string) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.tsx b/redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.tsx new file mode 100644 index 0000000000..e0dce54212 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-size/KeyRowSize.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import cx from 'classnames' +import { + EuiButton, + EuiButtonIcon, + EuiLoadingContent, + EuiPopover, + EuiSpacer, + EuiText, + EuiToolTip, +} from '@elastic/eui' +import { isUndefined } from 'lodash' + +import { Maybe, formatBytes, formatLongName } from 'uiSrc/utils' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import styles from './styles.module.scss' + +export interface Props { + size: Maybe + deletePopoverId: Maybe + rowId: number | string + nameString: string + type: KeyTypes | ModulesKeyTypes + deleting: boolean + nameBuffer: RedisResponseBuffer + setDeletePopoverId: (id: any) => void + handleDeletePopoverOpen: (id: any, type: KeyTypes | ModulesKeyTypes) => void + handleDelete: (key: RedisResponseBuffer) => void +} + +const KeyRowSize = (props: Props) => { + const { + size, + nameString, + nameBuffer, + deletePopoverId, + deleting, + rowId, + type, + setDeletePopoverId, + handleDeletePopoverOpen, + handleDelete, + } = props + + if (isUndefined(size)) { + return ( + + ) + } + + if (!size) { + return ( + + - + + ) + } + return ( + <> + +
+ + {formatBytes(size, 3)} + + )} + > + <>{formatBytes(size, 0)} + +
+
+ setDeletePopoverId(undefined)} + panelPaddingSize="l" + panelClassName={styles.deletePopover} + button={( + handleDeletePopoverOpen(rowId, type)} + aria-label="Delete Key" + data-testid={`delete-key-btn-${nameString}`} + /> + )} + onClick={(e) => e.stopPropagation()} + > + <> + +

{formatLongName(nameString)}

+ will be deleted. +
+ + handleDelete(nameBuffer)} + data-testid="submit-delete-key" + > + Delete + + +
+ + ) +} + +export default KeyRowSize diff --git a/redisinsight/ui/src/pages/browser/components/key-row-size/index.ts b/redisinsight/ui/src/pages/browser/components/key-row-size/index.ts new file mode 100644 index 0000000000..f139af942d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-size/index.ts @@ -0,0 +1,3 @@ +import KeyRowSize from './KeyRowSize' + +export default KeyRowSize diff --git a/redisinsight/ui/src/pages/browser/components/key-row-size/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-row-size/styles.module.scss new file mode 100644 index 0000000000..a8cddcd317 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-size/styles.module.scss @@ -0,0 +1,10 @@ +.keyInfoLoading { + margin-top: 8px; + padding-left: 16px; +} + +.keySize { + width: 90px; + min-width: 90px; + text-align: right; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.spec.tsx new file mode 100644 index 0000000000..24a0d7ba8b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' + +import { render } from 'uiSrc/utils/test-utils' +import { truncateNumberToFirstUnit } from 'uiSrc/utils' +import KeyRowTTL, { Props } from './KeyRowTTL' + +const mockedProps = mock() +const loadingTestId = 'ttl-loading_' +const nameString = 'name' + +describe('KeyRowTTL', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Loading if no ttl', () => { + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).toBeInTheDocument() + }) + + it('should render "No limit" if ttl is -1', () => { + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).not.toBeInTheDocument() + expect(queryByTestId(`ttl-${nameString}`)).toHaveTextContent('No limit') + }) + + it('should render formatted ttl', () => { + const ttl = 123123123 + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).not.toBeInTheDocument() + expect(queryByTestId(`ttl-${nameString}`)).toHaveTextContent(truncateNumberToFirstUnit(ttl)) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.tsx b/redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.tsx new file mode 100644 index 0000000000..1c52434606 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-ttl/KeyRowTTL.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import cx from 'classnames' +import { + EuiLoadingContent, + EuiText, + EuiTextColor, + EuiToolTip, +} from '@elastic/eui' +import { isUndefined } from 'lodash' + +import { + Maybe, + truncateNumberToDuration, + truncateNumberToFirstUnit, + truncateTTLToSeconds, +} from 'uiSrc/utils' +import styles from './styles.module.scss' + +export interface Props { + ttl: Maybe + deletePopoverId: Maybe + rowId: number | string + nameString: string +} + +const KeyRowTTL = (props: Props) => { + const { ttl, nameString, deletePopoverId, rowId } = props + + if (isUndefined(ttl)) { + return ( + + ) + } + if (ttl === -1) { + return ( + + No limit + + ) + } + return ( + +
+ + {`${truncateTTLToSeconds(ttl)} s`} +
+ {`(${truncateNumberToDuration(ttl)})`} + + )} + > + <>{truncateNumberToFirstUnit(ttl)} +
+
+
+ ) +} + +export default KeyRowTTL diff --git a/redisinsight/ui/src/pages/browser/components/key-row-ttl/index.ts b/redisinsight/ui/src/pages/browser/components/key-row-ttl/index.ts new file mode 100644 index 0000000000..bd31d8b0a6 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-ttl/index.ts @@ -0,0 +1,3 @@ +import KeyRowTTL from './KeyRowTTL' + +export default KeyRowTTL diff --git a/redisinsight/ui/src/pages/browser/components/key-row-ttl/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-row-ttl/styles.module.scss new file mode 100644 index 0000000000..d05353485f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-ttl/styles.module.scss @@ -0,0 +1,10 @@ +.keyInfoLoading { + margin-top: 8px; + padding-left: 16px; +} + +.keyTTL { + width: 86px; + min-width: 86px; + text-align: right; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.spec.tsx new file mode 100644 index 0000000000..170f56aea4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' + +import { render } from 'uiSrc/utils/test-utils' +import { KeyTypes } from 'uiSrc/constants' +import KeyRowType, { Props } from './KeyRowType' + +const mockedProps = mock() +const loadingTestId = 'type-loading_' +const nameString = 'name' + +describe('KeyRowType', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Loading if no type', () => { + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).toBeInTheDocument() + }) + + it('should render Badge if type exists', () => { + const type = KeyTypes.Hash + const { queryByTestId } = render() + + expect(queryByTestId(loadingTestId + nameString)).not.toBeInTheDocument() + expect(queryByTestId(`badge-${type}_${nameString}`)).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.tsx b/redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.tsx new file mode 100644 index 0000000000..45d6e920aa --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-type/KeyRowType.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import cx from 'classnames' +import { + EuiLoadingContent, +} from '@elastic/eui' + +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import { GroupBadge } from 'uiSrc/components' +import styles from './styles.module.scss' + +export interface Props { + nameString: string + type: KeyTypes | ModulesKeyTypes +} + +const KeyRowType = (props: Props) => { + const { nameString, type } = props + + return ( + <> + {!type && ( + + )} + {!!type && ( +
+ +
+ )} + + ) +} + +export default KeyRowType diff --git a/redisinsight/ui/src/pages/browser/components/key-row-type/index.ts b/redisinsight/ui/src/pages/browser/components/key-row-type/index.ts new file mode 100644 index 0000000000..772282a678 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-type/index.ts @@ -0,0 +1,3 @@ +import KeyRowType from './KeyRowType' + +export default KeyRowType diff --git a/redisinsight/ui/src/pages/browser/components/key-row-type/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-row-type/styles.module.scss new file mode 100644 index 0000000000..01ef8ef796 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-row-type/styles.module.scss @@ -0,0 +1,11 @@ +.keyInfoLoading { + margin-top: 8px; + padding-left: 16px; +} + +.keyType { + padding-right: 16px; + padding-left: 12px; + width: 126px; + min-width: 126px; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx index 60435f70ab..b4c35023d2 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx @@ -2,10 +2,15 @@ import { cloneDeep } from 'lodash' import React from 'react' import { cleanup, + fireEvent, mockedStore, render, } from 'uiSrc/utils/test-utils' import { KeysStoreData } from 'uiSrc/slices/interfaces/keys' +import { setBrowserTreeNodesOpen } from 'uiSrc/slices/app/context' +import { stringToBuffer } from 'uiSrc/utils' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { KeyTypes } from 'uiSrc/constants' import KeyTree from './KeyTree' let store: typeof mockedStore @@ -48,31 +53,58 @@ const propsMock = { lastRefreshTime: 3 } as KeysStoreData, loading: false, + deleting: false, + commonFilterType: null, selectKey: jest.fn(), + loadMoreItems: jest.fn(), + onDelete: jest.fn(), + onAddKeyPanel: jest.fn(), + onBulkActionsPanel: jest.fn(), } +const leafRootFullName = 'test' +const folderFullName = 'car:' +const leaf1FullName = 'car:110' +const leaf2FullName = 'car:210' + const mockWebWorkerResult = [{ children: [{ children: [], - fullName: 'car:110:', - id: '0.snc1rc3zwgo', + fullName: leaf1FullName, + id: '0.0', + keyApproximate: 0.01, + keyCount: 1, + name: '110', + type: KeyTypes.String, + isLeaf: true, + nameBuffer: stringToBuffer(leaf1FullName), + }, { + children: [], + fullName: leaf2FullName, + id: '0.1', keyApproximate: 0.01, keyCount: 1, name: '110', + type: KeyTypes.Hash, + isLeaf: true, + nameBuffer: stringToBuffer(leaf2FullName), }], - fullName: 'car:', - id: '0.sz1ie1koqi8', + fullName: folderFullName, + id: '0', keyApproximate: 47.18, keyCount: 4718, name: 'car', }, { children: [], - fullName: 'test', - id: '0.snc1rc3zwg1o', + fullName: leafRootFullName, + id: '1', keyApproximate: 0.01, keyCount: 1, + type: KeyTypes.Stream, + isLeaf: true, name: 'test', + nameBuffer: stringToBuffer(leafRootFullName), }] jest.mock('uiSrc/services', () => ({ @@ -80,16 +112,58 @@ jest.mock('uiSrc/services', () => ({ useDisposableWebworker: () => ({ result: mockWebWorkerResult, run: jest.fn() }), })) +jest.mock('uiSrc/slices/browser/keys', () => ({ + ...jest.requireActual('uiSrc/slices/browser/keys'), + selectedKeyDataSelector: jest.fn().mockReturnValue(null), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + describe('KeyTree', () => { - it.only('Tree view panel should be in the document', () => { - const { container } = render() + it('should be rendered', () => { + expect(render()).toBeTruthy() + }) - expect(container.querySelector('[data-test-subj="tree-view-panel"]')).toBeInTheDocument() + it('"setBrowserTreeNodesOpen" to be called after click on folder', () => { + const onSelectedKeyMock = jest.fn() + const { getByTestId } = render() + + // set open state + fireEvent.click(getByTestId(`node-item_${folderFullName}`)) + + const expectedActions = [ + setBrowserTreeNodesOpen({ [folderFullName]: true }), + ] + + expect(store.getActions()).toEqual(expect.arrayContaining(expectedActions)) + }) + + it('"selectKey" to be called after click on leaf', async () => { + const onSelectedKeyMock = jest.fn() + const { getByTestId } = render() + + // open parent folder + fireEvent.click(getByTestId(`node-item_${folderFullName}`)) + + // click on the leaf + fireEvent.click(getByTestId(`node-item_${leaf2FullName}`)) + + expect(onSelectedKeyMock).toBeCalled() }) - it('Key list panel should be in the document', () => { - const { container } = render() + it('selected key from key list should be opened and selected in the tree', async () => { + const selectedKeyDataSelectorMock = jest.fn().mockReturnValue({ + name: stringToBuffer(leaf2FullName), + nameString: leaf2FullName, + }); + + (selectedKeyDataSelector as jest.Mock).mockImplementation(selectedKeyDataSelectorMock) + + const { getByTestId } = render() - expect(container.querySelector('[data-test-subj="key-list-panel"]')).toBeInTheDocument() + expect(getByTestId(`node-item_${leaf2FullName}`)).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx index b289631390..9e9d2d10d7 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx @@ -10,7 +10,7 @@ import { setBrowserTreeNodesOpen, } from 'uiSrc/slices/app/context' import { constructKeysToTree } from 'uiSrc/helpers' -import VirtualTree from 'uiSrc/components/virtual-tree' +import VirtualTree from 'uiSrc/pages/browser/components/virtual-tree' import TreeViewSVG from 'uiSrc/assets/img/icons/treeview.svg' import { KeysStoreData } from 'uiSrc/slices/interfaces/keys' import { Nullable, bufferToString } from 'uiSrc/utils' @@ -94,9 +94,7 @@ const KeyTree = forwardRef((props: Props, ref) => { // remove key name from parents parents.pop() - setTimeout(() => { - parents.forEach((parent) => handleStatusOpen(parent, true)) - }, 0) + parents.forEach((parent) => handleStatusOpen(parent, true)) } } diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx index 9dd48c0813..de130e1c72 100644 --- a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx @@ -1,8 +1,11 @@ import React from 'react' -import { mock } from 'ts-mockito' +import { instance, mock } from 'ts-mockito' import { cloneDeep } from 'lodash' import { render, mockedStore, cleanup } from 'uiSrc/utils/test-utils' +import { keysSelector } from 'uiSrc/slices/browser/keys' +import { SearchMode } from 'uiSrc/slices/interfaces/keys' +import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' import NoKeysMessage, { Props } from './NoKeysMessage' const mockedProps = mock() @@ -14,8 +17,160 @@ beforeEach(() => { store.clearActions() }) +jest.mock('uiSrc/slices/browser/keys', () => ({ + ...jest.requireActual('uiSrc/slices/browser/keys'), + keysSelector: jest.fn().mockReturnValue({ + searchMode: 'Pattern', + filter: null, + search: '', + viewType: 'Browser', + }), +})) + +jest.mock('uiSrc/slices/browser/redisearch', () => ({ + ...jest.requireActual('uiSrc/slices/browser/redisearch'), + redisearchSelector: jest.fn().mockReturnValue({ + search: '', + isSearched: false, + selectedIndex: null, + }), +})) + describe('NoKeysMessage', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + describe('SearchMode = Pattern', () => { + it('NoKeysFound should be rendered if total=0', () => { + const { queryByTestId } = render() + expect(queryByTestId('no-result-found-msg')).toBeInTheDocument() + }) + + it('"scan-no-results-found" should be rendered if searched and scanned < total', () => { + const keysSelectorMock = jest.fn().mockReturnValue({ + isSearched: true, + searchMode: SearchMode.Pattern, + }) + const total = 100; + + (keysSelector as jest.Mock).mockImplementation(keysSelectorMock) + const { queryByTestId } = render( + + ) + + expect(queryByTestId('scan-no-results-found')).toBeInTheDocument() + }) + + it('"no-result-found" should be rendered if searched and scanned===total', () => { + const keysSelectorMock = jest.fn().mockReturnValue({ + isSearched: true, + searchMode: SearchMode.Pattern, + }) + const total = 100; + + (keysSelector as jest.Mock).mockImplementation(keysSelectorMock) + const { container } = render( + + ) + + expect(container.querySelector('[data-test-subj="no-result-found"]')).toBeInTheDocument() + }) + + it('"scan-no-results-found" should be rendered if filtered and scanned { + const keysSelectorMock = jest.fn().mockReturnValue({ + isFiltered: true, + searchMode: SearchMode.Pattern, + }) + const total = 100; + + (keysSelector as jest.Mock).mockImplementation(keysSelectorMock) + const { queryByTestId } = render( + + ) + + expect(queryByTestId('scan-no-results-found')).toBeInTheDocument() + }) + }) + + describe('SearchMode = RediSearch', () => { + it('"no-result-select-index" should be rendered if searched and scanned < total', () => { + const keysSelectorMock = jest.fn().mockReturnValue({ + isSearched: true, + searchMode: SearchMode.Redisearch, + }) + const redisearchSelectorMock = jest.fn().mockReturnValue({ + selectedIndex: null + }) + const total = 100; + + (keysSelector as jest.Mock).mockImplementation(keysSelectorMock); + (redisearchSelector as jest.Mock).mockImplementation(redisearchSelectorMock) + const { queryByTestId } = render( + + ) + + expect(queryByTestId('no-result-select-index')).toBeInTheDocument() + }) + it('"no-result-found-only" should be rendered total = 0', () => { + const keysSelectorMock = jest.fn().mockReturnValue({ + isSearched: true, + searchMode: SearchMode.Redisearch, + }) + const redisearchSelectorMock = jest.fn().mockReturnValue({ + selectedIndex: '123' + }) + const total = 0; + + (keysSelector as jest.Mock).mockImplementation(keysSelectorMock); + (redisearchSelector as jest.Mock).mockImplementation(redisearchSelectorMock) + const { queryByTestId } = render( + + ) + + expect(queryByTestId('no-result-found-only')).toBeInTheDocument() + }) + + it('"no-result-found-only" should be rendered if searched and scanned { + const keysSelectorMock = jest.fn().mockReturnValue({ + searchMode: SearchMode.Redisearch, + }) + const redisearchSelectorMock = jest.fn().mockReturnValue({ + isSearched: true, + selectedIndex: '123', + }) + const total = 100; + + (keysSelector as jest.Mock).mockImplementation(keysSelectorMock); + (redisearchSelector as jest.Mock).mockImplementation(redisearchSelectorMock) + const { queryByTestId } = render( + + ) + + expect(queryByTestId('no-result-found-only')).toBeInTheDocument() + }) + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx index 844966e77f..8b3aa1a83f 100644 --- a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx +++ b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx @@ -29,8 +29,7 @@ const NoKeysMessage = (props:Props) => { onBulkActionsPanel, } = props - const { selectedIndex } = useSelector(redisearchSelector) - const { isSearched: redisearchIsSearched } = useSelector(redisearchSelector) + const { selectedIndex, isSearched: redisearchIsSearched } = useSelector(redisearchSelector) const { isSearched: patternIsSearched, isFiltered, searchMode } = useSelector(keysSelector) if (searchMode === SearchMode.Redisearch) { diff --git a/redisinsight/ui/src/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/virtual-tree/VirtualTree.spec.tsx rename to redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx diff --git a/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx similarity index 98% rename from redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx rename to redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index ce2dbcee55..79cf3efaa5 100644 --- a/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -68,6 +68,8 @@ const VirtualTree = (props: Props) => { onDeleteLeaf, } = props + // console.log({ statusOpen }) + const { theme } = useContext(ThemeContext) const [rerenderState, rerender] = useState({}) const controller = useRef>(null) @@ -160,12 +162,14 @@ const VirtualTree = (props: Props) => { const entries = Object.entries(elements.current) getMetadata(entries) + + elements.current = [] }, 100) - const getMetadataNode = (nameBuffer: any, path: string) => { + const getMetadataNode = useCallback((nameBuffer: any, path: string) => { elements.current[path] = nameBuffer getMetadataDebounced() - } + }, []) // This helper function constructs the object that will be sent back at the step // [2] during the treeWalker function work. Except for the mandatory `data` @@ -185,7 +189,6 @@ const VirtualTree = (props: Props) => { size: node.size, type: node.type, fullName: node.fullName, - shortName: node.shortName, nestingLevel, deleting, path: node.path, diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx similarity index 58% rename from redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx rename to redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx index 427ecc8f77..588cd1df4b 100644 --- a/redisinsight/ui/src/components/virtual-tree/components/Node/Node.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx @@ -2,19 +2,31 @@ import React from 'react' import { NodePublicState } from 'react-vtree/dist/es/Tree' import { instance, mock } from 'ts-mockito' import { render, screen } from 'uiSrc/utils/test-utils' -import KeyDarkSVG from 'uiSrc/assets/img/sidebar/browser_active.svg' +import { stringToBuffer } from 'uiSrc/utils' +import { KeyTypes } from 'uiSrc/constants' import Node from './Node' import { TreeData } from '../../interfaces' import { mockVirtualTreeResult } from '../../VirtualTree.spec' +const mockDataFullName = 'test' const mockedProps = mock>() const mockedPropsData = mock() const mockedData: TreeData = { ...instance(mockedPropsData), nestingLevel: 3, - leafIcon: KeyDarkSVG + isLeaf: true, + path: '0.0.5.6', + fullName: mockDataFullName, + nameString: mockDataFullName, + nameBuffer: stringToBuffer(mockDataFullName), +} + +const mockedDataWithMetadata = { + ...mockedData, + type: KeyTypes.Hash, + ttl: 123, + size: 123, } -const mockDataFullName = 'test' jest.mock('uiSrc/services', () => ({ ...jest.requireActual('uiSrc/services'), @@ -39,29 +51,45 @@ describe('Node', () => { expect(container.querySelector(`[data-test-subj="node-folder-icon_${mockDataFullName}"`)).toBeInTheDocument() }) - it('should render leaf icon for Leaf properly', () => { + it('"setItems", "updateStatusSelected", "mockGetMetadata" should be called after click on Leaf', () => { + const mockUpdateStatusSelected = jest.fn() + const mockUpdateStatusOpen = jest.fn() + const mockSetOpen = jest.fn() + const mockGetMetadata = jest.fn() + const mockData: TreeData = { ...mockedData, - isLeaf: true, - fullName: mockDataFullName + updateStatusSelected: mockUpdateStatusSelected, + updateStatusOpen: mockUpdateStatusOpen, + getMetadata: mockGetMetadata, } - const { container } = render() + render() - expect(container.querySelector(`[data-test-subj="leaf-icon_${mockDataFullName}"`)).toBeInTheDocument() + screen.getByTestId(`node-item_${mockDataFullName}`).click() + + expect(mockUpdateStatusSelected).toBeCalledWith(mockData.nameBuffer) + expect(mockUpdateStatusOpen).toBeCalledWith(mockDataFullName, true) + expect(mockGetMetadata).toBeCalledWith(mockData.nameBuffer, mockData.path) + expect(mockSetOpen).not.toBeCalled() }) - it('"setItems", "updateStatusSelected" should be called after click on Leaf', () => { + it('"mockGetMetadata" not be call if size and ttl exists', () => { const mockUpdateStatusSelected = jest.fn() const mockUpdateStatusOpen = jest.fn() const mockSetOpen = jest.fn() + const mockGetMetadata = jest.fn() const mockData: TreeData = { - ...mockedData, - isLeaf: true, - fullName: mockDataFullName, + ...mockedDataWithMetadata, updateStatusSelected: mockUpdateStatusSelected, updateStatusOpen: mockUpdateStatusOpen, + getMetadata: mockGetMetadata, } render( { screen.getByTestId(`node-item_${mockDataFullName}`).click() - expect(mockUpdateStatusSelected).toBeCalledWith(mockDataFullName) + expect(mockUpdateStatusSelected).toBeCalledWith(mockData.nameBuffer) expect(mockUpdateStatusOpen).toBeCalledWith(mockDataFullName, true) + expect(mockGetMetadata).not.toBeCalled() expect(mockSetOpen).not.toBeCalled() }) + it.only('name, ttl and size should be rendered', () => { + const { getByTestId } = render() + + expect(getByTestId(`node-item_${mockDataFullName}`)).toBeInTheDocument() + expect(getByTestId(`badge-${mockedDataWithMetadata.type}_${mockDataFullName}`)).toBeInTheDocument() + expect(getByTestId(`ttl-${mockDataFullName}`)).toBeInTheDocument() + expect(getByTestId(`size-${mockDataFullName}`)).toBeInTheDocument() + }) + it('"updateStatusOpen", "setOpen" should be called after click on Node', () => { const mockUpdateStatusSelected = jest.fn() const mockUpdateStatusOpen = jest.fn() diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx new file mode 100644 index 0000000000..a0ae1b7278 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from 'react' +import { NodePublicState } from 'react-vtree/dist/es/Tree' +import cx from 'classnames' +import { + EuiIcon, + EuiToolTip, + keys as ElasticKeys, +} from '@elastic/eui' + +import { + Maybe, +} from 'uiSrc/utils' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import KeyRowTTL from 'uiSrc/pages/browser/components/key-row-ttl' +import KeyRowSize from 'uiSrc/pages/browser/components/key-row-size' +import KeyRowName from 'uiSrc/pages/browser/components/key-row-name' +import KeyRowType from 'uiSrc/pages/browser/components/key-row-type' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { TreeData } from '../../interfaces' +import styles from './styles.module.scss' + +const MAX_NESTING_LEVEL = 20 + +// Node component receives all the data we created in the `treeWalker` + +// internal openness state (`isOpen`), function to change internal openness +// `style` parameter that should be added to the root div. +const Node = ({ + data, + isOpen, + index, + style, + setOpen, +}: NodePublicState) => { + const { + id: nodeId, + isLeaf, + keyCount, + nestingLevel, + fullName, + nameBuffer, + path, + type, + ttl, + size, + deleting, + nameString, + keyApproximate, + isSelected, + getMetadata, + onDelete, + onDeleteClicked, + updateStatusOpen, + updateStatusSelected, + } = data + + const [deletePopoverId, setDeletePopoverId] = useState>(undefined) + + useEffect(() => { + if (!isLeaf || !nameBuffer) { + return + } + if (!size || !ttl) { + getMetadata?.(nameBuffer, path) + } + }, []) + + useEffect(() => { + if (isSelected && nameBuffer) { + updateStatusSelected?.(nameBuffer) + } + }, [isSelected]) + + const handleClick = () => { + if (isLeaf && !isSelected) { + updateStatusSelected?.(nameBuffer) + } + + updateStatusOpen?.(fullName, !isOpen) + !isLeaf && setOpen(!isOpen) + } + + const handleKeyDown = ({ key }: React.KeyboardEvent) => { + if (key === ElasticKeys.SPACE) { + handleClick() + } + } + + const handleDelete = (nameBuffer: RedisResponseBuffer) => { + onDelete(nameBuffer) + setDeletePopoverId(undefined) + } + + const handleDeletePopoverOpen = (index: Maybe, type: KeyTypes | ModulesKeyTypes) => { + if (index !== deletePopoverId) { + onDeleteClicked(type) + } + setDeletePopoverId(index !== deletePopoverId ? index : undefined) + } + + const Folder = () => ( + <> +
+ + + + {nameString} + +
+
+
+ {keyApproximate ? `${keyApproximate < 1 ? '<1' : Math.round(keyApproximate)}%` : '' } +
+
{keyCount ?? ''}
+
+ + ) + + const Leaf = () => ( + <> + + + + + + ) + + const Node = ( +
{}} + data-testid={`node-item_${fullName}`} + > + {!isLeaf && } + {isLeaf && } +
+ ) + + const tooltipContent = ( + <> + {`${fullName}*`} +
+ {`${keyCount} key(s) (${Math.round(keyApproximate * 100) / 100}%)`} + + ) + + return ( +
MAX_NESTING_LEVEL ? MAX_NESTING_LEVEL : nestingLevel) * 8, + }} + className={cx( + styles.nodeContainer, { + [styles.nodeSelected]: isSelected && isLeaf, + [styles.nodeRowEven]: index % 2 === 0, + } + )} + > + {isLeaf && Node} + {!isLeaf && ( + + {Node} + + )} +
+ ) +} + +export default Node diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/index.ts b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/index.ts similarity index 100% rename from redisinsight/ui/src/components/virtual-tree/components/Node/index.ts rename to redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/index.ts diff --git a/redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss similarity index 88% rename from redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss rename to redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss index 1af538d04f..653a14ad0b 100644 --- a/redisinsight/ui/src/components/virtual-tree/components/Node/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss @@ -37,25 +37,25 @@ color: var(--euiColorFullShade) !important; } - .moveOnHover { + :global(.moveOnHoverKey) { transition: transform ease 0.3s; &.hide { transform: translateX(-8px); } } - .showOnHover { - display: none; + :global(.showOnHoverKey) { + display: none !important; &.show { display: flex !important; } } &:hover { - .moveOnHover { + :global(.moveOnHoverKey) { transform: translateX(-8px); } - .showOnHover { - display: flex; + :global(.showOnHoverKey) { + display: flex !important; } } } @@ -105,11 +105,16 @@ min-width: 126px; } -.keyName { +.keyName, +.nodeName { flex-grow: 1; position: relative; overflow: hidden; text-overflow: ellipsis; + + :global(.euiTextColor) { + max-width: 100%; + } } .keyTTL, diff --git a/redisinsight/ui/src/components/virtual-tree/index.ts b/redisinsight/ui/src/pages/browser/components/virtual-tree/index.ts similarity index 100% rename from redisinsight/ui/src/components/virtual-tree/index.ts rename to redisinsight/ui/src/pages/browser/components/virtual-tree/index.ts diff --git a/redisinsight/ui/src/components/virtual-tree/interfaces.ts b/redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts similarity index 97% rename from redisinsight/ui/src/components/virtual-tree/interfaces.ts rename to redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts index 717cd34fe4..8d34865532 100644 --- a/redisinsight/ui/src/components/virtual-tree/interfaces.ts +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts @@ -25,7 +25,6 @@ export interface NodeMetaData { keyCount: number, name: string, fullName: string, - shortName: string, updateStatusSelected: (fullName: string, keys: any) => void, updateStatusOpen: (name: string, value: boolean) => void, leafIcon: string, @@ -43,7 +42,6 @@ export interface TreeData extends FixedSizeNodeData { keyCount: number keyApproximate: number fullName: string - shortName: string leafIcon: string type: KeyTypes | ModulesKeyTypes ttl: number diff --git a/redisinsight/ui/src/components/virtual-tree/styles.module.scss b/redisinsight/ui/src/pages/browser/components/virtual-tree/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/virtual-tree/styles.module.scss rename to redisinsight/ui/src/pages/browser/components/virtual-tree/styles.module.scss diff --git a/yarn.lock b/yarn.lock index 80a342afd6..b261b82fa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4585,10 +4585,10 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== -ci-info@^3.2.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" - integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== +ci-info@^3.2.0, ci-info@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" @@ -7091,6 +7091,13 @@ find-versions@^4.0.0: dependencies: semver-regex "^3.1.2" +find-yarn-workspace-root@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" + integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== + dependencies: + micromatch "^4.0.2" + flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -7516,7 +7523,7 @@ got@^11.7.0, got@^11.8.5: p-cancelable "^2.0.0" responselike "^2.0.0" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -8615,7 +8622,7 @@ is-word-character@^1.0.0: resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.4.tgz#ce0e73216f98599060592f62ff31354ddbeb0230" integrity sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA== -is-wsl@^2.2.0: +is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== @@ -9313,6 +9320,13 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stable-stringify@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.2.tgz#e06f23128e0bbe342dc996ed5a19e28b57b580e0" + integrity sha512-eunSSaEnxV12z+Z73y/j5N37/In40GK4GmsSy+tEHJMxknvqnA7/djeYtAgW0GsWHUfg+847WJjKaEylk2y09g== + dependencies: + jsonify "^0.0.1" + json-stringify-safe@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -9346,6 +9360,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + jsonpath@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.1.1.tgz#0ca1ed8fb65bb3309248cc9d5466d12d5b0b9901" @@ -9412,6 +9431,13 @@ kind-of@^6.0.2, kind-of@^6.0.3: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -11022,6 +11048,14 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@^7.4.2: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + open@^8.0.9: version "8.4.2" resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" @@ -11259,6 +11293,27 @@ pascal-case@^3.1.2: no-case "^3.0.4" tslib "^2.0.3" +patch-package@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" + integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^9.0.0" + json-stable-stringify "^1.0.2" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + rimraf "^2.6.3" + semver "^7.5.3" + slash "^2.0.0" + tmp "^0.0.33" + yaml "^2.2.2" + path-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" @@ -11652,6 +11707,11 @@ postcss@^8.2.15, postcss@^8.2.9: picocolors "^1.0.0" source-map-js "^1.0.2" +postinstall-postinstall@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz#4f7f77441ef539d1512c40bd04c71b06a4704ca3" + integrity sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -12679,6 +12739,13 @@ rfdc@^1.3.0: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== +rimraf@^2.6.3: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -13047,6 +13114,11 @@ skip-postinstall@^1.0.0: resolved "https://registry.yarnpkg.com/skip-postinstall/-/skip-postinstall-1.0.0.tgz#939d49b09ddae9816f089c0b892a8e4bb7bc7747" integrity sha512-IUVEmm4v7Ubzrp9JDG15oTzMB+abJdHcduXMRzBlHnHRrmpQ/QoPtYCRaorP+abAULTGEh87gPPyyMK5H1X1Dg== +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -15013,6 +15085,11 @@ yaml@^1.10.0, yaml@^1.10.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.2.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.3.tgz#01f6d18ef036446340007db8e016810e5d64aad9" + integrity sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ== + yargs-parser@20.x, yargs-parser@^20.2.2, yargs-parser@^20.2.3: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" From 8265f1eafa8b925308eae10367e9aaa0d742f46d Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Fri, 20 Oct 2023 19:30:39 +0200 Subject: [PATCH 03/96] #RI-4828 - Re-work the Tree view --- package.json | 2 +- redisinsight/ui/src/constants/storage.ts | 1 + .../KeyTreeSettings/styles.module.scss | 4 +- .../components/virtual-tree/VirtualTree.tsx | 2 - redisinsight/ui/src/slices/app/context.ts | 1 + .../ui/src/slices/tests/app/context.spec.ts | 28 ++++++++- .../ui/src/slices/tests/browser/keys.spec.ts | 58 +++++++++++++++++++ 7 files changed, 89 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 5c26df7596..7fd621a913 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "start:web:public": "cross-env PUBLIC_DEV=true NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "test": "jest ./redisinsight/ui -w 1", - "test:watch": "jest ./redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.spec.tsx --watch -w 1", + "test:watch": "jest ./redisinsight/ui/src/slices/tests/app/context.spec.ts --watch -w 1", "test:cov": "jest ./redisinsight/ui --silent --coverage --no-cache --forceExit -w 3", "test:cov:unit": "jest ./redisinsight/ui --group=-component --coverage -w 1", "test:cov:component": "jest ./redisinsight/ui --group=component --coverage -w 1", diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index be27828c17..4627732ac4 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -13,6 +13,7 @@ enum BrowserStorageItem { wbInputHistory = 'wbInputHistory', isEnablementAreaMinimized = 'isEnablementAreaMinimized', treeViewDelimiter = 'treeViewDelimiter', + treeViewSort = 'treeViewSort', autoRefreshRate = 'autoRefreshRate', bulkActionDeleteId = 'bulkActionDeleteId', dbConfig = 'dbConfig_', diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss index 08bfec6e5c..9118c31517 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/styles.module.scss @@ -54,7 +54,7 @@ } .title { - font-size: 14px; + font-size: 14px; font-weight: 500; } @@ -81,4 +81,4 @@ button { margin-left: 8px; } -} \ No newline at end of file +} diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index 79cf3efaa5..d46139ae34 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -68,8 +68,6 @@ const VirtualTree = (props: Props) => { onDeleteLeaf, } = props - // console.log({ statusOpen }) - const { theme } = useContext(ThemeContext) const [rerenderState, rerender] = useState({}) const controller = useRef>(null) diff --git a/redisinsight/ui/src/slices/app/context.ts b/redisinsight/ui/src/slices/app/context.ts index 46d7ee2daf..689788918a 100644 --- a/redisinsight/ui/src/slices/app/context.ts +++ b/redisinsight/ui/src/slices/app/context.ts @@ -95,6 +95,7 @@ const appContextSlice = createSlice({ }, setDbConfig: (state, { payload }) => { state.dbConfig.treeViewDelimiter = payload?.treeViewDelimiter ?? DEFAULT_DELIMITER + state.dbConfig.treeViewSort = payload?.treeViewSort ?? DEFAULT_TREE_SORTING state.dbConfig.slowLogDurationUnit = payload?.slowLogDurationUnit ?? DEFAULT_SLOWLOG_DURATION_UNIT state.dbConfig.showHiddenRecommendations = payload?.showHiddenRecommendations ?? DEFAULT_SHOW_HIDDEN_RECOMMENDATIONS diff --git a/redisinsight/ui/src/slices/tests/app/context.spec.ts b/redisinsight/ui/src/slices/tests/app/context.spec.ts index 75e05b99f6..6f4e6d723e 100644 --- a/redisinsight/ui/src/slices/tests/app/context.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/context.spec.ts @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash' -import { DEFAULT_DELIMITER, KeyTypes } from 'uiSrc/constants' -import { getTreeLeafField, stringToBuffer } from 'uiSrc/utils' +import { KeyTypes, SortOrder } from 'uiSrc/constants' +import { stringToBuffer } from 'uiSrc/utils' import { cleanup, @@ -16,6 +16,7 @@ import reducer, { setBrowserSelectedKey, setBrowserPatternScrollPosition, setBrowserPanelSizes, + setBrowserTreeSort, setWorkbenchScript, setWorkbenchVerticalPanelSizes, setLastPageContext, @@ -504,6 +505,7 @@ describe('slices', () => { const data = { slowLogDurationUnit: 'msec', treeViewDelimiter: ':-', + treeViewSort: SortOrder.DESC, showHiddenRecommendations: true, } @@ -568,6 +570,28 @@ describe('slices', () => { }) }) + describe('setBrowserTreeSort', () => { + it('should properly set browser tree sorting', () => { + // Arrange + const sorting = SortOrder.DESC + + const state = { + ...initialState.dbConfig, + treeViewSort: sorting, + } + + // Act + const nextState = reducer(initialState, setBrowserTreeSort(sorting)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { context: nextState }, + }) + + expect(appContextDbConfig(rootState)).toEqual(state) + }) + }) + describe('setRecommendationsShowHidden', () => { it('should properly set is show hidden live recommendations', () => { // Arrange diff --git a/redisinsight/ui/src/slices/tests/browser/keys.spec.ts b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts index de9b08a1da..1faf107ea8 100644 --- a/redisinsight/ui/src/slices/tests/browser/keys.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts @@ -52,6 +52,7 @@ import reducer, { fetchKeyInfo, fetchKeys, fetchKeysMetadata, + fetchKeysMetadataTree, fetchMoreKeys, fetchPatternHistoryAction, fetchSearchHistoryAction, @@ -1650,6 +1651,63 @@ describe('keys slice', () => { }) }) + describe('fetchKeysMetadataTree', () => { + it('success to fetch keys metadata', async () => { + // Arrange + const data = [ + { + name: stringToBuffer('key1'), + type: 'hash', + ttl: -1, + size: 100, + path: 0, + length: 100, + }, + { + name: stringToBuffer('key2'), + type: 'hash', + ttl: -1, + size: 150, + path: 1, + length: 100, + }, + { + name: stringToBuffer('key3'), + type: 'hash', + ttl: -1, + size: 110, + path: 2, + length: 100, + }, + ] + const responsePayload = { data, status: 200 } + + const apiServiceMock = jest.fn().mockResolvedValue(responsePayload) + const onSuccessMock = jest.fn() + apiService.post = apiServiceMock + const controller = new AbortController() + + // Act + await store.dispatch( + fetchKeysMetadataTree( + data.map(({ name }, i) => ([i, name])), + null, + controller.signal, + onSuccessMock, + ) + ) + + // Assert + expect(apiServiceMock).toBeCalledWith( + '/databases//keys/get-metadata', + { keys: data.map(({ name }, i) => (name)), type: undefined }, + { params: { encoding: 'buffer' }, signal: controller.signal }, + ) + + expect(onSuccessMock).toBeCalledWith(data) + }) + }) + describe('addKeyIntoList', () => { it('updateKeyList should be called', async () => { // Act From bd09d61244b056b41dbdc7cce69cb55ad51cfceb Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 24 Oct 2023 13:26:48 +0200 Subject: [PATCH 04/96] #RI-4828 - fix pr comments --- jest.config.cjs | 4 ---- package.json | 2 +- .../components/filter-key-type/FilterKeyType.tsx | 4 +++- .../key-tree/KeyTreeSettings/KeyTreeSettings.tsx | 1 + .../components/no-keys-message/NoKeysMessage.tsx | 2 +- .../components/search-key-list/SearchKeyList.tsx | 4 +++- .../browser/components/virtual-tree/VirtualTree.tsx | 10 +++++----- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/jest.config.cjs b/jest.config.cjs index 24b0c9f37d..66ae5b8524 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -39,7 +39,6 @@ module.exports = { 'json', ], testEnvironment: 'jest-environment-jsdom', - // type: 'module', transformIgnorePatterns: [ 'node_modules/(?!(monaco-editor|react-monaco-editor)/)', ], @@ -61,8 +60,5 @@ module.exports = { functions: 72, lines: 80, }, - // './redisinsight/ui/src/slices/**/*.ts': { - // statements: 90, - // }, }, } diff --git a/package.json b/package.json index 7fd621a913..0769e63f96 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "start:web:public": "cross-env PUBLIC_DEV=true NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "test": "jest ./redisinsight/ui -w 1", - "test:watch": "jest ./redisinsight/ui/src/slices/tests/app/context.spec.ts --watch -w 1", + "test:watch": "jest ./redisinsight/ui --watch -w 1", "test:cov": "jest ./redisinsight/ui --silent --coverage --no-cache --forceExit -w 3", "test:cov:unit": "jest ./redisinsight/ui --group=-component --coverage -w 1", "test:cov:component": "jest ./redisinsight/ui --group=component --coverage -w 1", diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx index f75df5ae4c..43585a4b64 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx @@ -75,7 +75,9 @@ const FilterKeyType = () => { setTypeSelected(value) setIsSelectOpen(false) dispatch(setFilter(value === ALL_KEY_TYPES_VALUE ? null : value)) - viewType === KeyViewType.Tree && dispatch(resetBrowserTree()) + if (viewType === KeyViewType.Tree) { + dispatch(resetBrowserTree()) + } dispatch( fetchKeys( { diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx index 8febd940db..95d401b0c0 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx @@ -38,6 +38,7 @@ const KeyTreeSettings = ({ loading }: Props) => { const dispatch = useDispatch() useEffect(() => { + setSorting(treeViewSort) }, [treeViewSort]) useEffect(() => { diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx index 8b3aa1a83f..a0f1762608 100644 --- a/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx +++ b/redisinsight/ui/src/pages/browser/components/no-keys-message/NoKeysMessage.tsx @@ -21,7 +21,7 @@ export interface Props { onBulkActionsPanel: (value: boolean) => void } -const NoKeysMessage = (props:Props) => { +const NoKeysMessage = (props: Props) => { const { total, scanned, diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx index 01824f0aa8..fbd3ff302d 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx @@ -67,7 +67,9 @@ const SearchKeyList = () => { dispatch(setSearchMatch(match, searchMode)) - viewType === KeyViewType.Tree && dispatch(resetBrowserTree()) + if (viewType === KeyViewType.Tree) { + dispatch(resetBrowserTree()) + } dispatch(fetchKeys( { diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index d46139ae34..54cd1795b5 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -44,7 +44,7 @@ export interface Props { } interface OpenedNodes { - [key: string]: boolean; + [key: string]: boolean } export const KEYS = 'keys' @@ -81,7 +81,7 @@ const VirtualTree = (props: Props) => { useEffect(() => () => { nodes.current = [] - elements.current = [] + elements.current = {} }, []) // receive result from the "runWebworker" @@ -90,7 +90,7 @@ const VirtualTree = (props: Props) => { return } - elements.current = [] + elements.current = {} nodes.current = result rerender({}) setConstructingTree?.(false) @@ -99,7 +99,7 @@ const VirtualTree = (props: Props) => { useEffect(() => { if (!items?.length) { nodes.current = [] - elements.current = [] + elements.current = {} rerender({}) runWebworker?.({ items: [], delimiter, sorting }) return @@ -161,7 +161,7 @@ const VirtualTree = (props: Props) => { getMetadata(entries) - elements.current = [] + elements.current = {} }, 100) const getMetadataNode = useCallback((nameBuffer: any, path: string) => { From 5cd5af825e7dabca8b011c9a1e2e5b7c1b9cbfb0 Mon Sep 17 00:00:00 2001 From: pea-sys Date: Mon, 30 Oct 2023 06:54:11 +0900 Subject: [PATCH 05/96] fix: Optimizing png files using zopfli --- resources/icon-tray-colored.png | Bin 2251 -> 1279 bytes resources/icon-tray-white.png | Bin 1601 -> 1026 bytes resources/icon.png | Bin 37645 -> 30024 bytes resources/icons/128x128.png | Bin 9021 -> 5240 bytes resources/icons/16x16.png | Bin 3264 -> 762 bytes resources/icons/24x24.png | Bin 3996 -> 1465 bytes resources/icons/256x256.png | Bin 17151 -> 11154 bytes resources/icons/32x32.png | Bin 3538 -> 1710 bytes resources/icons/48x48.png | Bin 4692 -> 2585 bytes resources/icons/512x512.png | Bin 37645 -> 30024 bytes resources/icons/64x64.png | Bin 5778 -> 3359 bytes resources/icons/96x96.png | Bin 7910 -> 4810 bytes 12 files changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/icon-tray-colored.png b/resources/icon-tray-colored.png index a6c3154729ea3a8a722beaeff6f7a00a5da41603..4511d9cadbf93d411b895d47843e21f592bdb433 100644 GIT binary patch delta 1260 zcmVJZiAMv&E=%0!;lNJ(mEYCPTEfa=I}Ki#P!7co|l%U$yjirehMf(e7Q08 z@N1XjS;P^>or-3zHvQ#hF5 zz&saVG7O%Dqg}56X_Sa*@F0K`q9h<-6Z|pl^5XRs3kYZc8broil8cOhX>4b}`57Hf zH#!S2X`4yN@QbqE3Yc_RL3$O_(l}w{2qyt0XpxG`Kz|H#iFqMl>?L{W{GYh&EWl1< z5?gPKPsV&00)`QnxhgHoIt#GVFmbMQ%co@grTLl)0DD1=)JA?xQmDez>uXwSC|4!7Qp)uH{0SF_!5?a z{2Q(IUGpie{!TL51;Hvz0WjjCoV3X#R!bm&ptE#X83jzof6W1n?HwF@u(}|__G%|U zL);+t+0tmc{bv|#&VgyBo@OavY)q4(F7G6Q2!C630aK9NhqhZ2*Y5-mGN#Wr{S?%nOxuiD&KmZ&Bn6%F(WaLHm#5Y0B7u0-I z1z6A`GWY^}!8Go@2n(;KKq^ItMTSSK2!<-Z1O(6>K8H#^%C&$sx3toT!u|;0fcAy! zpaE<$<6{fg#FsRJm5%~OU7UND1DatgVC@_epJs!uK`YI(D_}>Lyg6kpjoFcPYo+~N zC%h3*a>`l?eS$=-&|%O)gvU96`=0yX{(mfhAY^&TA>vV3M*?I6P1N(&(h?|+1r zaLw+#CU-G3gl#S|bTpt3AC`a(@IAy`UX(l&K68C|YPQm9B;YMLraoVlJrZ7mnIMW| zE3Fy==GEe5>HXnB80a=LjOomrZAZTn}wwC&+Fv8lDIWTa*O^ zB;jY+2vUMw&;@RB-=BX10*;4wVKGP@Tj4h-2Fv*`z@3BPY4`|EbJ&ivfIXSL0{#ND Wn}g_a*6g7G0000v^gJ3ZrS`@$dK~YgEA}UDK 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 b;EewOh19VC@2Sjg00000NkvXXu0mjfXb!!E delta 1585 zcmV-12G03{2*C`HBYyx1a7bBm000XU000XU0RWnu7ytkT^GQTORCt`_o6CjGDMGYE(2N{sp=+#%SU~Bw(!uVLJ8D3);V1SGc0}gYUaMQ7m+&Q(5Pzm_)#WYYP zE2G+O1KN#R8|PjI>;kR;hk(D^VML|mEzkvQ0UiSG0V@9gH-KZn0pKTKx-5grB47Z0 z=m&uAl)e{$gD4s2-J0c=fO)`n^ugEV_`LwXphO%2t`}!o2p0rw03HFh0<$f~nTxKR zWxy&N`G}&NV1Ey=6L=WtvmC1z7zCCR`Uq#RA)pG}i!SlamSa_bl|UUOvoz!}ItX98 zfW?;cSAezXQn%SIxnW8`T==rZ4B(D}#>#~>0tV21y@T}dbyK1h0k+}GCAI-afvKVf zj1q7s`nGMumrLve#!=~f5IAW&U_iiE{=W-j)*-rq$AA6yC-4pM12Bg?c zK7qZ&?h8ro%N|()u7(q6Sjvm6PSC{8Oe-R90#ilz-5S(C3;`E&j@4EK zQQ&Q0Eaw$R z?A)RvR0Ey_-olQE21>$7;)RuK0s>5-ImEX#6@OiueLxK@ZmdV^ac9tceW}63&8Ydm ztpg*dpJ+?z8`TKg{B{vljE8`cU=W>jZ7gh8H4ic$tH0yvXzG1^8E4~^2 zV1ISAevlVam`PZV{3~E4`oj8wS>RFfK1VvJ-F^eL<_x z7uK?Gni8Bw9pMn^ifUhqXP&CFpd&& zhU7!cq`j9F9vwtLfPYaRahl{(THt-_Xn$6j2Ob?rK!66C86HQYNRnYoQ9qG2o%L`` zz-Hie;0f~YTLI3Z+hZ)HuZOOP6_`0p561)qsH4fyv)IM=ON2rv$&mTzp-UP(N+KXY zAMhdYAa)LH0wp4@l;%>f8q6qd^0WEQiqgd^^hI$ACTfCr8b> zKoxilconlGB@U*gw6c`oH1GxR4)EjuIw#x}-UPe`>?Y^s10SR4_84vPH-FazOaLDP z@1if`#?A(prB?$l0?%W2ZKhQz%_RZ*(2RC0v2%vo;@xPS;APZ_xsiZ|hC)D`2mPEGW`u zSKkbEp_eT7qT$pA!|0Xzq>_7XxcYE%3iuSQ_Ur(@B>SguKLbyp$&{0a2MXT;N$qwK z`9MS_5|;Y3hTDG%AW&Xi~Bccpp`W jrK6n$bOdhsugCub5rFEmS4iVB00000NkvXXu0mjfrjz2x diff --git a/resources/icon.png b/resources/icon.png index 00fc47f1d3e7c6d061fe6c2617600f17b390025f..e8d189cce3a807a39f21851bebf881fc4519c20d 100644 GIT binary patch literal 30024 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 diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png index accebeedb178101026b4879a3a01f8c1aa479c72..f5e66fef54d7e5c7815bfbe2d0d576aae4ab6cd7 100644 GIT binary patch literal 5240 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 y-vlxKoe_Y4$bk3o_kIvn)-c*_t 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 VGC&;wFeLy0002ovPDHLkV1k{DXPy86 literal 3264 zcmcIn4^R}>89%TUs!>6siKd$E(nJ#6+r7QLe=8?F?oK()AvrGyI9m61-*GEHSKuXb8E%>LYaTsD~qA{b!m@(=|NU#V>P>V)t+R+U3?Qxu>gNCNf z&Ft;lx8M8z{J!7!y|?RgvKLGkH)EVirJCT#vbo?rNx2fo!v6>R1{iqzNhm8{QmND_ z$`u#hmQe!dp5fhja-Q=sniT?Sh7&wM9S($`v`Uqp5e_kI5s(oNDCC1?^lEbpitwBn zU8-{u&X5)O_^fgfEGf@+v*kssi9<8yA?aZnDhL3XLBaulP@=?UP29sK&rzoRw4nh(nffEK?LtrFH6M9;wMWR0xS`#@h z?Xt~}+JaAJ)F;a!8plgZOVy=XwICMa8k5O{6C_TO7?i-IvY^a_v7nSPq`?LxR^&r6 zF9Z=qBjXW@Witvr9Sk85im44sQJ5fLc$f*{8a1JW6cOaum|Un>^hbI`kWY{W$tT=Fb?o)G3_xg|&X|o6eF+3&CM4Nj0>g*`GNQEPE(-zN1tg(ZWP!Z| zx|uQ*jU>CkZ9GS@9EwL4hiIz^7+DbAg5V#@a?Vh^2uZ35B-P2Vd{9B)5yf6~39vCT zFr!M+F(u=Aw}zr&-bpfz(9#4kDC!hA-di?Yl*BYTw+52KL~Bgn5`~Q87@7G_VUDG} zf*4>RUVMNl1b8S|h$68p&{n}Oh|naqcmDQFk-BcWH81^ahOq2sWE7^dQQh^^m=AU+hUQ2_00H( zYgN4Bphk+-0S_!Z%*z4-qgXxkO7CGX%A_?ID2AX+UZYYq5hBuAyacmc7Olf2V0Col z=aC5aXogj4%8arK9{`6&r}+`^7?tJd`cfZ&O7FswLFl}i~BSPCI_qu?QaC-)_$ z=I7A8|?^k5ziaF~`P2|~vy*ny$ohdY0pw?m};ex65hVSP-n5Wo(N zqu)Ok(x5k)bUGadIFAm-!ssv~;2;?&lY#UAKtVFyJ(giL_=AVT3c(T}4&zpc5gEA8 z08uidUQq}jOeo~%SwSy}r)n4D8gQmYpZJYpAT6Qa9$k^WC7>VwbjQ@W zN!H&zGOg`$N>`k-@o-mr?dKmhb+=3Jp0KdkyfeP{R#fcGuMqOrTu*#{^SZ|Vq(41V zku=w6K))$T&- z{1v98*70?@v&UDf&8cgd9)9Au__UtR&R-*M->eUItT?gokNvOzqOo|aP`O%L^KR|e zNt6Eh?5kPhKaPv9o7|9{j-)4DA!nthe^<~eUReRgGIz-wK2 zb$#RSXXV}Ve!>3g#~*FGa`Be~uY6s-KVjQbeEjQ_xG>fnY(#zwQ5#s`p?h2bpM9FbxrSlVaixG;bOJZ{hQr(>f{?2Y9_w9 z>qeQJo|(4ih31Oxjhou)r)pD=U2AJxKC?G<N5`;lqwOKn{Y11vfaKS z?Lfw->Z6@|bMrRY6RxUWTYKif`2q4@&51%;VpZwD;=b9Jr_3?k-0|os13B0d9ti-~a#s diff --git a/resources/icons/24x24.png b/resources/icons/24x24.png index 7e6655fb5dcaf4c4f22ee42f2e1f1fa4703a7a00..26fd2cbb1c6908fee06176ec1f0ebe449aaa7dae 100644 GIT binary patch delta 1448 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 diff --git a/resources/icons/256x256.png b/resources/icons/256x256.png index 175ec9b4fc77bf20e73239fd4896e58f45973f25..fc5e0e9f68e5bd08eeea3231a1fd591d76c58703 100644 GIT binary patch literal 11154 zcmXAv1zeL~7ssEC7%@Vr(V#R6qXmh5P2W|FCRW*Z1=g(J@=k_&iDN86RUq$gNBNY3IG5aElqU;0DypRApiwA_{Y+x z)DZwg&9&53jRGKBljxjlrU={~!>psRj7FtvUqf4L+n`8;`z-i7Il=R2|HmI=r0o5j zC6%L`-)~)<938aP4>joT0qW`y`sZ~8Kuipj=hemQuDWqoh+@&q2=_~W8ydZ4%Bw|D z*CT#(`nBosb@_|;a3|&5HaFSd!W@XaS)4 zuh@&N9Y<5}r-^G*7@)$!D~fAa^1_+0jb40;WGG1TKPk4CwjvckNVG+bJo81j>uttf z6nT+M&y&14Qm9bBg@HQOs#{7G{Yp_*H8D`4G8yXC4d_mnHZdUQX%2W3iW-Ur{MTbj z69KM1WwSyK67aszzC|4~*6g!iF`s)lMkPAjr2Ca}wUJF-RQR61iesOJfHuK6jHzPc zJmyC{WLiB>>YA&Wic_jau}#}mE>3^r1(xnz&v^1}w$K;86ZbV918r9#y?J{HgVV=h z(JBug46Y=Nutk4vN0`_rnfCmo8mo6DIbWK!3vcDmQfb^j^IZ9pzKFgw-CkABS&3=dG55 zx5J-hoCY|*&z6(j6p#uHg^?8))_(qos>lSaZ zr*wxcTE)q2;zo=t=D}cIxz>!oDt>oT0_O<7?x!=G$i6lkV)vb^{8?-4Rl`N&FcFf6 zJ7HCTCTZV-Aq^LTZZuBUaqFPhp_18V_vCfDck9f-)@59n6|r~s+;OLZFstxUtjfXh z+k1ZVI(&nhjusp4WBu^(;e21s@UWRCo3j~4NJ@%KJG_^sepY(uLvV-h?VTxxp5GRN zas!;kJKU?!p?c;0k3*$=SB6h%E30z{&BNNS{gy~(XjLO`hoXMnS+gScU@)9AM=jdsy6!_>M+cN@XGBY#UsnRI zu9>3gKtnCRaZC1E%Lf3lN?X}5hG56Ad~aAg3s9;?onxNwbW%5$q_`{!u-OQ&NfGaJ zu4zyY*-IQ{fOee`GKfF$7&Z;n4?b7+E05tLgh(fGlAQA^wEAR4#E< z4?-V#zcB3Sw$gYc$`nW-!IIH?FlWJ4OvJp0PpGg6Hv4Pgw3}FW55TC9weE0Ul=Ytm z6QHLJd@rbWHq(G0KW}w}K6u0G6+(p-WCHRi+hF%;&N!)zsDOE=Ffx?iFSYrnT;q^+ zZOFprRy7b)wTkABsQRpm5Fo>P)^|X8T6$GiQKx>pG=@~z_yxw1~L*$avER8QKlhQ37b$`m3N#cifQ+NZaOUE zVFyu3pWa-A47+t6IOJ(#WjNYmkr#$DL3ygY2m4T@31WW-KWU>aGZZ4jXC;SC5{Pnz z)@1kr5)|yv-#L<&Z>$KYIf3~-&AFju1EJ>x#-hH#tghI2EYyOQna&pGqhB)u=cK@- z-wy`5d=nD<3L9`+%RZh{!I|x%g&aCRZS#Aqf-q3E)-NcVV-mE^J;t#oJlEL%Z6Yq% z=*zJI(4+v2;7U+bK)L+-p$ANWmyxOE)3oDqERRZ*n znwbX5Tc;1h=}5~7+bRD%W9+nDg*X-eX+!8$JD!#iEwY(_zaMQ9{{DKOyuKVNHmGKA zOg~4=?PKY_eaXS_wu|UD9qs_nVgo>(sIaD^@AL@*v@o*?QHB&AhGl1J!%Y2uM9{p} z>9kKB)bDwhu>Vz1Ul_Qu=1IJj@;LC3Aq4BAiZ82OeXZ3k?QE>fULbAgg;iP>csb4e zEsVaD5g31GTvh#B`Vz2#0tz{Nku=5+#t3D$lz1=G;u}O`M>$pG#D5Jh`E#~+B)M^Y zJ$rHo8{3g1Be~ZnbvB+~ppqkT*_Q1R8L+tY3`++;?aW6LkZ3}UtqGKeu61M=a`5S5 zOs^UZUJH*pg}}(C^q%Ji9^uBVVw>K3`w7=V7rwAd_U48 zExjE4)kEt!xb~WiS)u!iGPaG={KvoAt|~y2-#Q|ej2UoLKt51&tNTzi|K8uQ$kW}k zYh&+WtIMo;&zu6SL+E-CfPT8U&HY!=hnn%MfJxl$X~{K5cRW2V|2 zMLk+ab^?;+Fc#C_3bW|TeHB%x4dF>0HF=ma7*6-;T2pPSt>Yix`-o5-`BVfGH{j-} zzF}vi6-w_Bc3$KY0`>&aUu6IQ83qT17sjStM$T~HBe2+O?fxoo?OoNvk{WGiLs}sDYQvYGJND$LFXr0^!3%Cl>2>Ve zdvM^A2`QXGw-^&+-(3t4z9Qkj@8ZX)e?MTjbWI^KI|ht5fgU{Do0YX1QbB>ruCIO? z@+t@Wlc+obyokONHnHaCyyn}>LSH$LX=t#?QH8FEiBM!?7jll)g*(rnMC+=GJ|~_E zyU2fZbSJ7-uDQ$g=Z9||c@o<@;+p?Yy9+I+QxSX%8dK);oW}{I*fkt)HQy)XhpGov zeA%V70-z@r4m}ABLv6o7VjrO&cUro>c|G>IETo(Pm|odPsrc%NS!Ko(42^0 zgTMGuzZk1D2IzAy($F0U0bMlc<`g#?LUs_9hegPrme@YaoRo=D694}9vcMjkE%>L0-IzGgG!)X_A+Oj~4#c!5WuWs>j*&wO ze3%>NE(n*d8gF1hM5^@GG~8)i<}a(1?){G8-!yc~L2}!F7W(#0CP0L%V#!)T4g1LwT|=nualf z5ykR`gk;l{MTWEy*Zki+L?)E6HD~4Xj!ikByKkXcS%9l`&J^f&M_i@?DXsSMTEm`x z<5sHY$Lpv_D8Th}y3UQ&LVJrtPzp@9x9K^aBkyFf;Y{N&0nX2|kw$>}OQ>xNhyC_T zjz}mTP1v<;>9<2DKVi$xQ?LFn9(^ABM+GFQBK6>egFyafB3FeJ4pS+=k%);vLBL{9 z`LvG?YpOblQmr4b)@pc6V972K{E*^?!hdFk(G&v|&!IdUTihyu0g*(XSW%`nf&6}i zC0S<9M&@vMAVfgm8FxBAhU{K(cKHP5;lns*=UG~a*e<*bi0Y5RguHw}hJ|8TpOT8$ zz4j~^{zpR&l-C#PFp)+ohx=hAB7a=AM`(8uW#HLdU?65ikNvC0wXjhFCRYnGiQJ5& z6G%7e(U)vE(SuCepA@o6Zws1=KwoeHfz{)u5*4uX6zUA|yL}ac7oiEEn|V_n(YaC@ zg>ROI`?5+UPc1Jbe@=fHg^_}wAoSLR1seSY=j(^_`mV15{KgMrm>N41slN=|BE>Dp z4vyYB({HKRvefeM0lT;6-uJx$zaIH*P6((8qB`VVV`bTA@bBo zN{}Tnc*;_uWi^-xL}n>ta5lQ$!xXr){JJ1s4>c0Q#*Kt;S^et1O3%*dhOp9JP*t_> z5|b4{R-26qYPSO{g+Uz1`Ryv{`K|GW3d4TT3_KiBV#4H6YVoavm_JM z_9WVDxO)AG4K`o?<_Bd=tOVk9s}eAaMr}S?vnhPR0L2bLuwR#T-F>8pk5Nw?&Lo+U z59F_Qg;%%!_FaZ$q z#o`_gHj@{&%&+`dQ8h|)f*19UB`0nL<#ZbgaJaJWKA*jmOJhaBITJ6YoOHv=;>M7B z7dkZ1$)fhdbnLO713e?NmW*H(Xx+D#<^=eXeRX2FOHTTEqrVg1{bhCJg9uLW@KpHeIRxG(h&@DgIsrmK9G&9fCRUB_IXy;#sGs^q4 zZ8Sj}15mtSp`4md;XK`Mg8Orr7dMyl#CQ8~8i6%tN0-y3^|u0Y@E`L^h7q?ayx@BL zQ`>xSveFCR^Lht4;WAmWy6Ku=e1)WdB7`AGXz3bX+w6$JB;!1`)_Eo*SCqwrx$bc* z8?IL>^Xa5(3m=Apo`d6dmp&K*l2;>H8D)o>5i9?qlVHGDnuV^Z+WC#=+{6g1C`5Vc z3DUeT%$T>2hlU?l$c8H+GhxGFG+Jfg33H+Y-~dqEMhD+orf<#OsX2U3%po1SS9Wd& zAgNBs-u*Bde}=C!NqtWc|DV| z^K&_N@`zX}=6A;dU1g3m!HWl}mGDnq zZ(rkhX$VESh>yrEpR7D0&ys{22^jKtdh?<-1YVBd=M`ISHc=G_?vAA%9TS{$T8_@$ z;dZ|#d@DEIs4xwm?(CNMCZ0(iBM+p;DZ>K%UV8Wa2^?cb!oMJMLM)`u<8y?4y+6>QFRM)${=%!Wkb z3e^)tanCH9hoy^K%s#dTGT@9G*JI~83orpUk77HNUU#KKxUP8QGXm`p)L*&0EzO-l zYu}}M#h08h31f8TKX1cOF`G}q{iZ4%U8@ebymQx;vpd&R7clvxaZL&!jp9R|%gbj- z*dod>$2x!E-ssfTt`E6yfn9Q7pChi;sdwVo8ffj;M?knbj}rlGP4Lfy+>8z0No17W zFUkTX((otYkJIC#m=}90l+Le@v?~?#krXQdub_CPy5j1d)fYRSYqEmYlHp&tL;3*h zNyf&tmQiVLzLJ(ey#IE3@Xy*v6dc|4mSZlO|8w0F+$^gm4dH36HiT%(61qI4YQ)lO z%|()T8a&{g7vPRnOg$v7 zSx8ig_F6w$kQGW=F5i|~zY26&I=KWYFLxV>cHX5^ST&pBqMxa=?ljK*)0qxOd8M@b zr9)9{y~L^VK&M~{+3iX{uL{i%H=h`9NB~_oS96>!S7oyhmks^4-zUYgkUse;cuTG! z;b&)PxtSE)f|H;R`|hm?!HYHq^j2E$2;8rHI4V(FqeLi?^kKB{+zKSaQdCJfnKn;` zUZut)?l@LVW=DZpCs3@Nx;*k`(9PjhmA=~vQUs|%u$X2ZXsc|@z!!CW3}h6aYzysx zKdDmoM19JTmmD9C_t^SFjSV6sJN7p3C#}?Flk9j;r+N~Cxt~N>*&!tPAB*qP>i9Z7 zbd(NjdQWLYg%t*X4ZCd|ahzMsQdf^=rF_F-vilm4QpblVWhP?`mBatfVA>TYr^3Vh!a9y5n!&u4HCd zONkbaVwIxUNOF=**tYY!?ZQw4v(+{w)zF0KKb?{y>08<=#zam3`*qwFHvZ22b)OPS2M0`qgBfX== zOa}k#ckO*;IygajoC^Zclkq4{%}$RI01H1T8LEvD=xb4;C)5-_;{T)VTiw=O31Ps6 zB5*_x33%W;Uv3(-Z_y|1YAmksg!Cc?8_^?aZY{gt2gB`67t$BQd4H27;H_<1h|723 zEVUWy8&FTzSj-wu0bCO@6e&S}|5S9Jm1ErB$AaFq{O7J+$q!o+IAQ2swk=KuF5^-G zCMR{W@L>LCrjGnVjI)Ki4h>obj(*#jIA-_?qX{_wz6vJ51$8#uA*^UX2&`n-lSKy_ z{LKG?$&C_O)H!t)^9^u4k_nYCyr7Y6wG8+7XGeF~aJlk1lO&|Q$OpCR;!mI}Y&qK+ zxk!ro<6(ng>GFH_U}K)3u4JZrJx@E#s4z!*WuILTLCMH>J$KyGzx~>kF!?cS{_X8v zT;>IPwqvnFZY9+6r7u=<)H3W4LC=wCGo*)slItY0mlYsQk4m zylM|(#odR0`r&)dRy}-~RzrqxSjcse&L)THGr0>f0O(NV0lk1J@k?hyFEULMlR9Y4 z2X}Xm5G;~~w4Pq`s^$x}&c0yh-LPO_#6yb5Hgq0=%g#jRTro{?U$4uUEvSuIU#`<` zE|1vOFpyq?vf&bJhA+mj%Y*5+hAnkfi@k%<#fxNmh(>kNa=ReZ8mtt~;@)3viAa~l ztabbc?$R+BAxd-?_DjUha{715l56z{oWv>V-SX9c15C_`Y1A%Alv%7YpVHb6RauI?)iQ$wOAMVGc)4sjw)-ox>_c60;`= zZ`8LhI(@F5;3>{x>o@pwV7XMc0CX7rTq1n!Hx=}aP8|Zr!b+(5gu+iC=a%kuB`YB& zv|A4kro=eQWRi#BL;n$2#Y_RWYA_S2tXWz^Zj{;e3>wZH(Scpc51NQ)7z(9;`)^{^ zvs>c&%>JA1njqmrZX$VGsbmH+N6aeaPaY*2xrg~=PYt`}6Hpg;EE&wQv+W#H^MGeg?prUkyHL3z}vu)ATz zVk&4O4x@0xlq%Hn+w;B(izNdP_a(lm^HJn97#&_Oy8h+w9deC)A@?ePUKmI;wlAC} z+Axu(Al=IO;crR|1TamZMa?P0#Z7XQ1SR(wiq=A|y-g zuBV%2=N)RjX0VoBqOuJK$pIUUe(^XC~BKCzHB+8D}{7o(;FekfRN70Km2hU?vP8Q4FSj-!DH- zzj;}%u;D2DpNNJ1d@ z*(c0W9clwf-*XPgy9w^@veQe4GS!e~_#TE#FVH?q+ z1sxm~xqs_E59}ay>~6chQD~eh(|y9=UDCFif0vz5Gj_!7mz5_3ETusU*iUHRNeupA z+V-c@wX@f4?s+j(S0wj9y!t^Fh$oIZUCEJ$`-vRBXPOAm?DPmeFgo0jF#tZ$p8$TW zhrbz~#3ih-@HtdJGk;i->BsIW`?yQL1r(Mnqpe}EVx9fF7dk->pD-W-o+#zMDZ8ue zdw2|wFRS}CS%8`PHm3{$D|Qrt{{F|j9{^4T@9a5(lum!zCj=ydvVQAOPq#gQziQuV z69M~?O4Rj!(Wb!X-sbUBnEZfz(0V#Ga^)^!G+Aump#GsOSC2M!-n8fK#TaFUfjl46 z=Bd+YsoSHS3$xs(ZI1%=N34P$o`-07!eH%zyKfZy_p_UEi`U-ZconGkZZ7}6WpfLT z2KAOA08co)|98>K^wG**az9#nJZV(EVT;M>hU3)aqR)tr#nH2R+uI>fS*1d!E=Yp9 zpew>hv?s0b!q_P79q0$<`u^+JL#nm`lib7 zIa#t!#V4=^X$zs zQs4xyn_2OHe{S9DRCo(30KAF-3gIa8q4K!R9F3>hS@Mlz(WCNL1gO^;YC2bPe_Y|c z9eL-OwX`@z*(>w+*02EG>>XHN`ynkOP-p;rZXW%JM3)U&M`vujMjS_1Y;rVWH0AyG zn!2!n&q$OpR6y*o|1gU+`Kyo7-+?pM-D%fnpzl4-fU={B?qV{pRuj8jAg z@WBq59@VmYjHAsZOiE0@}I_JEu-;ogNX8Plrchgj|H&LD%-zz>2{Zb z6nKW?#|Qik9aNK1)M4Y%dD`O0j)HrTWI}oS&pW%I6a1KSO=2N+o9i4y zGaNt=xN=THvU1PiP5->lSG6_!2bkRiT;R~zUC;R$|m#QVFRf9`jRGM z%oYrCA`^M@rc{Zm(i75O9)ox>Hbxn-a_2u$O|bn-gY>Xw)}*TrnLWL7Vrpb0NH^nV zhIgfo?yN1gQ5g1Smm^kqR^s#uBF#Xyh+#BjA|TVj1CB_HSs5$tn&7rp7x8uir675J zfc^dx2{`&y@BeLLz!_S=;Aj(?l|9}@SWavk0HJn21ObnEq3PXK!S`n}{}D(IlHiqv zz?s>_Vfu@k{6=GMx?HPYT-cN!2tfxECdYgQt6eQJf^a}eO2}4kUL*Ocf1u>;O7h2BU z7>uUGPzO0~F?2m=IToYI6N@o}lxhRaahae-Cv0^|2&e#8znV`_zCNqYG*}?5xEjj` zR}yjzB7R0*%eJxkYL0(_`*xe!nubCM94aWly`>kpx!Q!|{QzOZopvL);-oM_GlD&L z%vIL~>=BZfDdg8AFjD~MH0XvYWz!()x=9`r;PTSCKf7c1{cLPGLj;zRFdH1VQqS&5 zFxqmDpSfUl>^TlYy?^?a5qk4y#U-I!-@#5=8#uu`@@vw>)V79C@W*p7U4oK;JO%SC z8*U&A({yE?t0G*=)8kBQHRrm0h$dpD)Vt@iKvTD$isT52Qu6%2RS4K&AR_O4NnB=* z#WY!{DF)li!cTMTO3n<1{iMo-t-fTgn)C|5&tMnWun<=6E^H<6U)=4#o0T`UQX1-~ z5~Kzvum@As6+bMV7S=Ze)Kj4g4n(HpN28tPZ!tG|KO?`_ayl(fi89}7E%u^1plM7u zA5No0&$n_51BxG8kN{%=%?JgyMs(nlfv_RLizMRa(Zu{C@rYXn!$wqZ=Hy<9n!OmC z!Bzch)B$zxxBrm99p|j;_+3{-U8@+3i3SaSN4rAtK^t*dGpX~MqVu!NM|D;TGzHi@tn2T$xgdT{{X=V7**OMzrB2gBbb43C-@HEB3MnsJ&QfmsXOuZRH+EZi!{2 z1FFcYxfS6B=0E|^bU7&dG%%42Ffm{2BZg`=!d^{3(g)iOzitH2B{(*9yXg0v)0nvbm)jlA_?+Q|ixxKXF$?Rgp3 zTI5A#D*X7R`LMM8&6}qN{YDUat25$Qp_q_rt}|e!#Cam*_a>rXTU!$4)eee~tUuCy zmK?<4=qmk3ni8K#jxFQW#K#;0n3`+BJ+l_sPSJK*TVIeJ_;uc-!%G%Ed&uWt*IKf; z#_52-m4LuciH_j;7sab^^?YymdC99iX<4+pI=G3pamr9725o(B0Pg~qW;u!L!b`x{ zyBa4hgJoixVm(-rL&J`2L`Gg?=K2|`S* zs|L3Eho5$#7X}*RNxULgJLv{`BkSP~5lsc%WN0cLGU)7idbAJ9p=R?m@gZei^Mr5=W?@5QsE) zR+ftlCEEH|Tca8wxs7&km%94`G&L)utxJFyQ%?#BP&{;dYo*m*X(=s89-SjLa7vW< zBSwbb+qfIsq{68!1pFlmIddHfl7(9_Y0{vz5Y{##89okrw!yn^>Ti=je(gNq2ZF`g z{z-Qp6tCh?u!9E6#Ymo-+$aiC0l0ZilySIY5fgB(9+nd85PvGq2>jc0a6!;o5q1^j z<;|WC?T;}6=yIE9iz|hsVEJnqLf@jR>cvM95O4OS4F7B9N{{Dxbz3lQd4n1d3O$!C zzb@w_&g$Enr{rWm+92;Z``%G^TDLLvC-?kK!sBzr+|^(mey4LeKwP?y4Gnzo-et#D zu0?TQ0!WgL{E-a5>C-oW3bYO>K+Yp0V#g=ODp-@OO<@V^)p^t5oL6b)RrmhYCrHmJ21YB zOr6OR*tR2g;Q>DwQdvt_ju{LDj;^+^KySZ>M!->G@4=Xs16yFba<>5XPc0eJ|HjcQ zwzPtL!StG%3MngA&5&Bki~Sxe9X^Rd5_nq0fjiaNXJL;@W+X?26|yAqZlntE@I8m< z!`yCthS8ujB}ocO%;JQA*>PFj-^b)A840Bysd*J@Qc>?D0g<@E8wnZE#}Xv(=>b;Y zEYah}vkcTrnNH9NeU*W;xo`1M3S(JZ_l%Ane3R|Z?S_{ zGL?vAq%Fd5qC`?va#T+t96VlFGHptDTl$QLq+scgr6sJB1;>I!{Gd-klcOqYFYPJO z76$KgZw&yYmt=XCZp>Wi2a2Kx?Kw7D#o#CfP3Z%13?-o;oeD?}-7yRe62gF_(%m6~gfu8MAmSjY(%qtf zic0gpdEV!J&U^mX_nq@yhwI{EV&6N~UVE+e`>nNkqOYq?M#4k_0)fah?%gp2fxy5^ zFo=i%_+x>5?g;!L_P%H43jzsqU;lywD{lLPKm^t<#-=D!9c@`VFArgay_c94iza8j}uyb=jao9RIxp*pY9dz|@ak$tkaG6Nzz;wJ-9h_b61tT4df_07Ug5B)k z_FT6WIphOnfet(zPza7d4|h*r*+2!ZzxtI0USEGK!o~4d6O@|*m-6+19Hu(@9I9SO z2M#G=7}O316XTGE3yVrgNXW1ag7Es?HPZS~$>gmh|yiqP*o*dUbB5b|XV-tM1v z-hN2;zXrFr6LD~N@Nn=%`2uZ4|7q*(?1l33b@uviy8h?u|7`$3v^qNfjPYO2#lz#D zA$(DH{efltEs+1RwXboIw}XhGgRhq#($3+oKQK-1>(zM6sv;c_C@-Y3mzVp$H%kBC zQ|1s86NYi{>mcl0Jg*la@E?yj+(Dom6u7SU4SKz4QpTbZvcT4fi3!5QWnr*?HP!L5 zcX15*&!%EfQAuM_V1K1#B}D(XrhwAeBT$I{Yh!ymSw}CV2Lf2KiwDBVLB!kBiHqYO zLCUInxqBgjfq~hH|L5l#s;c@(FGm-5;0s?vbrlYcyQ)&6a49LMn6T(y)78~hfGN6E;+=kHhUE*yWUk1WFO`dBD%*3ezlJ0SmK0p18C01}Py4@*`9J%^f%4+`UuyN=j^m5)|33@qf2K75TK#_t>AyT5 z|8GM2dkgHG5uQ#CfIS!Cx?YaRwdMPl0*L(ob>pw^{=-WCyE$NbuV4OS^MN=2cnt?n zplc-H6H1pU_yKpKrg2BvI57X)o1kp_S+k`Rx$U|}hX)RyyB+WUT5=_Bd4R|C^eI?_ zgC{yVnul%-^3H}?9)n9E)kToO)YJ+)I;;dmrJ*rUbWWp ziVKRbs|_rUo%`%3jnr0jWsbb2V%+{CUt)OQ$svC~@odR=`DfSxU%DX!1U+%*!5h%y zK7Cd-5Cl#2mMEeR*3STjF{m+cg9tE0c6hxu42Z8SJtql-2b(sFi10;(==+8{_pxH` zVDdwPm6yFv5r4pf_n~Uww8BBBZMhLVQAs!NeVqb{?MR;@!na|vE=yNGEtIF8eH7AA zw5i&~c^A1U!z=Qr9;E&y;dG~}8^?kdgnqV~7x4$=MQ}4R0CySZocz#9$#C{P#5|rV zVq%r@aIyCfsJ{D45%G~OFywT^ZJbL`pt~Tw^Gq;-Q<1}r&=Tle>a_ndy#4oDPwrV{ zI3$$znDS7!xI2eb!Z>yo?5?*@xD>ZE*Rw<3Jr+Py-?U@dcNVF>ALxgU?E4C8rBFP) zxuO@L9(k1@*L6INBswNu@$DUoU?=$PKK@6_IBpgUxlI8+04)(Lyum;C^I>q0emsS! z2S3!7bZNSQm;ub7%j8UWMHo4KzZq@WOF>(&K0Xx3j^`-&FlAK=h6A$*#Xa@By18<1 z(9H~sO_;u|p3r+1(Q}9WE;9ij?8jrCM-z*{DqjXd$J9wZTI?N-pGL~b8^69}JgnN`D!biB!$?M>1|mqH-tj$S2U!_@e<+nIOv4KUes=;(q(3z3rH{Nq3#+reUHs4rNa4jg+e%1e z0Qk>?*O|8h!@hSu#jukcKkkIycF_{`PLd=c@YG?B0fY%fl;Xk^%SdEh02+~w;LzZM>C>}_I6m)r2k=D9{`_~xmqWT}24NR*~p8!9e4EevKi zi#FnAOg=(~!{l69Jli)&GSeX7`TaFQZV>Yz)zR5fDmlP+}?U9qvFl05rh=jFy^aFt31xM?{A6d^PVoVcy?>Bq!SyquIQ(AV>=!; zb$?|3u~1eXDnMAL+aCgw+}OOjJe#>`LXUJhg6GbY1lQ5lxA&y6oo)ky#T%oSlQ(62p#;xYQb#P^V`js`XLnmtG11q0Z0 z^An)!ZD7J%z}lY)Ht;0Q-k44mQWHoK`6aXXWTzb7(mx~Ev_rVsPZ)+EbV(M(|C4gY z^1MTWRU=y@NVAhqpy#g7K$y8%dGml4BYv^hvQ>M2X}__7z<-_ zd3PM*MMMbl_tfrmD6Zul)?RY=f%cEUM%PUV!GXLrsv;?XCX^_H{j|iWqeK0wvE8|< zQ;fC!139Tngh^SN-%=DE7{jXHx6sLTLpqXv=FZ0G85cf`(ZO`?P&Gg(oaA81> zEQbG_)dtTz*8LnLeR{rR)6QEev~7cNX!ELna~9Go7*P+x(h7T-H!$KHK19`DwAm5Z zG3-YO#rjP5XQo=U?(ZD@iKIN1TuF)8b$uXn?w-E>h~{`XVhqf#oVAmiuQiO}k$ITVSk2ZzVGfV!aj_JlTIFD0+_Siqf~s7f3`n&p4XyJj#?V!zL}KD3XOVck+4&jhPeb zlSFdk$+0FF_YLx|o)2bQ7Je|st~+C#MyOD!iB@$Z5_A~<4VlA~_~}^MbH}>lV!^wB z2b=|YJjR!uQ|NrMeUw*+C&W%x9^o^@C8#E%Ahx`kvl(Xx&BBnSSk~!CV|N@#Djw{S zK+xZmL^Z$p`I&du-Q4_|A#+hi`ewr^hNCvv11Hij-ZOJuqmO(nXesZz$1j`>&G4yA zkBK`&lHo3mSR+9yBeIn0N^G>-Gk&7f+S}oy&XEYy;e@=|Y`0a-z6}1&YEKlZlJ)Xn zQQ}NeD=zJRz38Yf62B`nFXU|~Q_$t>%LUdJ+${AdKds3I9zZoPA~l~!S}_t4sJ|odE}5p8h18sAt1Nxj|6Pu*cD7({tHtc@T+scYb)0v`qAZ_U zJHkW#WL!l;F7YbJhH?4XDdYtX!xCgnvU%o~7IS>l#5?Q>juCl;!6XPNaRp zW1Pdj!bdko{iywer1;*-lYg{$8=9b-`^U&iJEjCd$$zlcO(VgY0K?|E#E#$*XZ4$= z7_{+1hw4!1QjlQwB>9CG)A6g7F7GD} z=uqH8@3$bdnC=Cejb_nz$#>oGXWKJwsy zVNvrnT%hf&fQ#+o4%V*iK*Y)otb0#qaDt1T-Ns_c8C$ z13s~WI5e5fo5m{Ud0{j$pNZ{gtZ0t^kqgb(A6F1b$a|liIvV-*vbT+uv4Z%E63Y#7 zNAGXq?y|@A(?t=FabZ_gvc0?W8s^(_n10Gw{B%Bz^30^VNy0m_0K>d2iSG*hKquku z@l}VAswyg53o%*m)W~v6@^{@$0mvI5q6`@d{e4PaAi>9<^ph;O=)Dte=6fN$uinrf zulf&>u~h|6t6%kxx9wDFGNTzV)0TA0ci!!K)Umj{lM=Rx(~$WiwwO%S^4;NPjPO|P9l$rSxSeVTOEnr+_K$m$ zT_F9Ae!g)d26jk9I3UME9rX$_mZ}}>f3-)r+28YavHq_hr~1+|T%3U)%x~tz@GzC% zB-8`Gv7tGzipDf%j4{EM5?2RFIA9mt{wmbs zk~-Xi#M0xNs^~Z21sQnM)0i4wnLP!}IXNIkiznjl!%D_q73ejrB=#-t_lNNP)gy85 z!}si-*hc-k+f!u;3c7XCEG~iCk_tEF^Ncr9sre&BedC;X_8nZBv_8+;Iq`X_*LE|q zb2w~yN1xB878J(le=_-MnbI1Du*VDfBJ%3(!62b8oI#Pu*WuD)byh+{P`+! zxm|B@k}Bl>A`qn>;pk6FN0;~hM0xcAQG6$LQ@x|_>s_DXecK!erbM|YVe0lKn8V~= zwVPV}GPy*xArWWbcl#t&zc-5_ix7;P$#A;ZNDeY7S-TplcGlgIWA4xtjY<5q$@Qc-b<@4)|dAOA}qUDH8bzViehxcT+ zbwht~GjPgovQ6X1ClXa|JhU9|D9z9i>%&$_{rSt5#Ar}@`t2w`^eQrCmILq-fQ7WH z_j(LtM5E=(UZ1f)m%E@&RJJe2E4F`$FkL!vdK@>aJ8+qcZLGxhbv}mjeBX}PTH+Dmf2r@RZ z5PTRJc3-I6_5EnSsQGkKJr85JFMNe@Y|9z5c@JaP-*VUKK0OSAFxoOI8&WyJ1nB2_qFBgO`>s~dMq&??4Z=U33CWtc@c*~N2+tSwqJO`G; z_NIWtNfmAe*$Y&$O~+sQfaO~jHtp!=LVp;>p1>SNKJh-!%t>|Uj}sq^>{P`z-h1FX z_!~^|puLtESr+}00@n-23g=wCxXnS33HE-RNRQ;EJo8v;J~7wj7u!5N3)58;rBM^~ zW+sOCGxj>!r1KhsNEGN! zVtP|>t>$t?+IcjE3-y)ndc0#RYL7!?AR4 zS?>X^*US%E+6C8l`|^j_o$Z_h+)G!CiNu}S53u|rp;C+Q8H2*?h32b26D;R6Ms>#= zM_NwB=ac9dJ~!Y7${jlOnnV=g?=QZ`U+{e2inBx!N_`l-@|d}`l(U?L&7g@VtviYG za0B^nNVxVeM+;1fc%SXoh-qr9;4pbl6uIhZMRYEIh<8}!j5w4&(~;(Kyb_6!#;wPB zM14B8( zq(~B$9P~?2aNo3JlK6Fp;q=+zBZ6bj=F|(pl~g6U($t9;h31kFd^yX6N)ORxt&^io zDR%hs)^G(SrzU3n7!S>i#5s0pX1rRCWXEG-J|Xz=WjWBWF66{;NQkck2T#)6dX(f7 zh4fBcc-YIlt=o&(A7^tKmuXp;qbGwhdlXVEEu}fHY`qtreMNahNv!`QWmObw>o=)K zY5dG5o-W;e_sHc{pEqka?J?Pkz=}zviPwO%r3taj)|@QPVnKhdcy946Q0xzDsle|kX-d4+%9aEy9N`X;*BIon(P>xc#I)w+39 zt_L+>RUfESK8|}VnFU09ZzP$kiwib+Zg!4%+jW-&Uf>*BM=mQ*GcHK%;T0c&?7zF6 zlwfIi+=M6Ed-UF{I-JR_u=bi~t~sj^MRMa4ej$%#CeI2H;FF`;mKFUbQH8)h1J*U2 z`qhjFp1*%5f0xcE)tn8$uy(C?`LIM&ES%9)IGA5Ek-wK85gp$+jowd*+}%D)V9p`* z#yKWmS?tw1^U`;xLROe2i?dE_n!SFtWaBcjZA|HVYaEx^t(6V`=D<$2Z4Gpo209#% zMf6*UAo`o&pO0h~yk|DyC~<`&ypiNyZ&fRc*N@mPoDZ)`_8@ihnSoU}91~ju6s0M` zU&c-b2di2Ow&G}*GB;L-WdmB|9p$L19>)#x54Hew&2K{aM3PQkGuQ)r2LH*#u`xpV z7F*H#BeK~jXum~Hrak88D%eZVN)HsCD)`8xcl~!R#fNVsy6Z03d$f z&RaumfHIhE1A!chZ_8MnopTN+w>5@WU5}{vIqc4vfVJ6|AE>UOXK277hbJN0PsJtT zVYTVgw%E^0Gsyh!N5~W)sN@*-uCS?c`;+p~3#C&_9f9Uv?}U#1d3D}bFYF)mnFPKt z?)%|pXkKl0zR}=w58{Tmup^0-{#W{b+g42=n7KAu96yANljf5pZike{KsBVeVcs&sFu0K zG7SE)^}} zb<*@`(Fk<^HH$#@q0pp-4=^J0z}IRy7Pod5Ap%EZNmujYnr%aT6B3Mf+jhfPKn{hG zE^W+Rg$R<7t+Km}lsrgqxvnS)P<0f6V5KiY$Gd9i}zz zEtD$nBo&wKscu0+9UV~#ZDb8wT^@BhgQtM8faXLM6zWvkC~9Z#-PCTU8Zu{7XCk-j z*5Ug=BwoPWKqbH}t<}M;-;2!OU1dw&a7ZKDXZ@K?Yzr6{(ins(yC|mNoROqN9CC(U zwT%^Dj1wIy_0(!2W8lzy+_rzuHM2ZzBh;gMs9M)RK#+I6?$Z^^^03oMC28dQyGgXv z{&FsFmq!TYn{0O@8Ff;K$8HLfskS~KviX#?V-Hu(G-~4!$$jn{fZcXCVSgg@Rvy|K*Ma$<(6>^Kg^SVfKD9EUu|}WoH?`Q2~XK@b5VQda5(AspqnFh1OVCY9e$! zcpa+0Yd+I~Z9RM)YVtV5t|&)$WVbZxeO3(O-A+0avsCgA9*=Ja(~Kc5Z>hBu9A!mK z`e~+bB*N)F6EFb0yBa~%k(nG^U10I*Dkg^fIw+9xyIiyvb?tgZf?2zWlHgNfMpNj0 zZ137dR@SuHCK$v!X)t9f(TOe*V0=r)rIoEU#i~!kEBC~Bw0z#CX|XzXiGJfLyU9yk z*i!O#>1Bfe0EPkz5`r&a*r2bPSx#AgF{@8RQ=V|0bKMg&Z21-TZByf}$W-;)Zr*mU zg>BQRb$7j@j#2eFnENcGl)2G6v$j;2}@vt)6{C}F2gQG-k%UiR3heq+69F@}Okt9opRptUliCFyG z(+(@mN+e(oosu5BXk6WLo=F94-7LqKaH(=iz;pxIqIo*9vClDb8Fj`@yg2$u12BllrF>C=R;#gb6aY)nDn4vQU@f>9>_sN(c za~99Amwb9FqswBSQ7eaxOE=&Kz_dL?FeDpRH?E$hvqgAVo^3ytIjXn;Hr29{|I-$4 zFt;eQP5P6y(26|4|3Qkz*iE6ri5e-0GQH#Lz|ua2b$C@&+$@K!sIE^;-7H&$p&jNI zVR=>W1X$Y;i%AMk^x`|JbJS{VE z>tU)1Dh&;F@X2&ji()3oi=>K3Mb>O+X?(~UfahBf!)fmT37&;dKW5kGO;yHqjn-=h@*oqJ5p-Dt+f7OSd1F!NiP?jiio9H6&sIQ&%8np1`M{%o;Vp z=Z@gU0nYF!T|*tptvM3RBg+?0HSGx@=ra)q^XxP&w2JzkTjYL)6#&N5u z_yzx}y__wlW4ZbZeFn#P zn9g^hz9*Sjz&eHUIm*W6>fZi^hu)_k;oROhN0!l}{B_RM~1JLv9 z(5wqT`X{5XT3Z=cxbPc@_%ETpFgwY8*UvHlzevs{94#6pHey_w&Xlw*htcs>pjaVZ zQID{`lhD@{iEG~RT)9LZ&R@mjq$xU`0H04vDB)v^WeV0r@)_JQBhN5lAW4=0g8kpN zmGh$8ouZl!653LctG5bk-24fST~;_(4$5K>JO=48ccTEtVxh$}-bhhyc@^U;DBnnf z(}~~ixp!4={^lE9Z7YxU6R{{6yZVD4Ng)qk`(McnnJfBKW>XmThwg=NK^_l(X#OCA z(}N>V^qTs3pt8=nS3nSA-Z~iVMpX&7w%9pbB^3-k(Ts&*XLn~4Z&&W={T}$S;u#4orYMx0kfSH`xc-$R(7O zmAviaTE`zNq}aRK0b{&o8f94+<29`LR+;#SLjFBXRzHn^jhD`W6{Kuf?e4Y$@j1ks z{V=U}ee*U$i_?f9(RGR>)n|H5GyTUd7R(Os>0ifkk_?u683{())Ex8#pFZPwj||RH zAwlTktEKx5MUdP>-p${Bx83euVG1?Czd#sja>kgZMp+vkdf@|L(5v$J)M9tyUfJi@ zhE_As`BDLt{0u$4tbEht`aR6HDb}vPDNcOHv9sabi{kBGSZ6IYNUOK}0Jb_Cee&xA z=aV`W(x!YLldE6;sjHgGPwfXZIjM4J&izY2dhuyo-dd*fA{PG@5XIvq!SaMp-L1iq zycmT>EM^`}=JRECY7=tC?vbnKN&1IK6+4@xBb*s@K9hnPrrUySUXNx-yxAwzFHX=k ztT?QA6cvYzxldDOuSpxO2O=?fuP0}yEyOn);y7=xt}Rl@>Ll@Xg1L5 zxgDdT!z5XKy6d-WNGHZ%5-K(`+I2yAalUt-I-;hS*(r9vh%1Xsn%}pB{#t@_5}`qc$P7!dhS3 z+P67MDd!LALnLigisKn$*XukrtbijnuQfAE4u3czJF_{D5@!{u8?P+rYoKyB=6H8q zBDRb#Gkq5IgM0o1QdZ!&bp0Ict*znyNY>Q)&)bZ`Lq1h`e9L8(dxsfu3SZR06EHvPJN3@Fu>tw zYK@VzwRCsjD;*CC&rMhim5w$Y@95slNs5a54{2!Mq%+k1UnK!8^9T z=&^D)s;wXgfMH)DdR)QN7-z}f z@0lby`5$@6(vejdMRvcM*|e9NoN$0%$A_c8#;E~^?l~X}en)dw!8)9gEn5ce>)db1N6n;!@Cm@Vj35gwh;xV^ z#MrcXJ-*RA_0gDtsSZ&9+EB`^IkxWty!Sw4Zec^$P}^|Ea}-jSF{FjELdMX$eHX|H z`IJxX7qV0!mg7Cxc?tRDROg9X5vfdBEIrz_B=r%jqHOIv+dCkj4e=Q3y?y|t}! zU7doMY|C*C1}f0BWm7$00IVXfR9*eC;{^O9xS>*eqR}KIcY(Qb1kn9}_av&yQ>|)~ zFdCI3L5N9eVvpI*;gROM@0Hk+&Y{antVMRVd<4}Nfbe}6XNTH>#fzv56j2xzTJ4H{ z@`len7u}tYHq)(oH4HzU_Rm#*p+|1j+%&ofEdl_14iJ25dzTl(qu?hOW`wQf$x@J> z(2AinK`M&Y>425^-$hFEZpi|}S!Hs+XbfFrD#J^>ik);5+L1OFMQpTIIS#nQ3 zpQRl@R4UPj0y3~)RlKpnsZ`U3GTmqaxW*Dn@}cn?7I zI_9ZlF{W0@)QYz@UvSk5bs_AQ-Xl}xT73#f?*+ z0&m@W& zhsnCP1L--|mc<5+5!$iZ=zLc`mgn;8>Xh{OGP5inp|-7U)9wwHfbP%SPnjwG`lC4?|j1YT&@`pHJK<=_KLXc ziJ`?4Lm}{)D>-1==rPeo-?D)$@uqyqu4!Oqor;)>4THawB}nrDkl}9061cAZ^t1E- z#F8wjKt64mTUeYY(C@xSnxM(4zmsV%VAJ0;W8&tmx3Q~~%z@y1|J*z^mlr6RVQ_8L z27NZ9qsu_AYUY1`w;55TkjG7yRsHrsL}lO$NY4; zXNU&IK%fjjBpVHNkqPeKzs`!cs*+2~S277x7pf+g7TDX@GCcltjV)!x7qE!8^tDO?wMCWj9xbq?I(lVkyJ11#L8;=1g_+d#jf*gRNgD?bg z&=#;E<*Q9tdel_DRpq)!>nq+MkL+DT3v|xh*LrC)B5?pHICXOVM5rvbKA#Wv0}{RN zQmP^#w+;s24(miZ__QU%+w{ z4>Qsu*=Mi!DcuB&n>Z`YEZz<^r4dkecCWSNuatej37toB5Lt~r|L zp09#yUOYs#qKbXikVc##RQTv9c?HOi-xbJh_0+h|p&O~GfHY}jc|P8VDyX|#2*6D5 z5_<7fvrgWQ)kbX7P6d21o(v;4f(rkz1za9b9LXl;8ujPiDoa#*HtHVT>)n-@4yxrf ziHHz2Pc35yYhn)XVKz;Hl{m(G;$sZXX7NZefs$t+-ly$iUSK{0sLFiTto2EJ$@yj; zX3v5ed=3FCZq?Z*rfn!LZxL)zXmu?N1(2aRNA8mrRQ8NPqnUqLYm>Fchzu7cPfA$< z_!vCm&+RxbYO+RSp15_yje$;G9a}*`T@lq#%l8r9-!@nZ5HhR-EH8FXYgdAeqQ9oOxn(yheRd^7=F~z1A zfT;Dr%OtPajzw3J%-ibvY(3Dcx_VVNKB)U37}Nkd0pO|q&-}yL5ce6;W_9hUyrwD* zJzL0zeIK}Hwu?G|@@sh*${x(7cxT`R%OQ7fNJJQOM<-&<6$5)uYyD<&9VmC40D`Fe zqj(Yw58q1;Oao0}hV7}rht$x;QCXm*yGkzD;xv@XLZ}+NLzXh1yB}>pHZ{c*L5Gdxh9_1Y-m8zu31u6e>z`QNb?lL#Pip?2N;4MLZ_uy zBE1#nCkbt57Dc5QTLI&htIPP^Kc#x6$BjrLp?AV}hC|a~(+N4=tjR6S2$@~;=xR?T z+k`3}(&mz!jk^GOwxF<;CNSu(r-UQsYrNEau}-=Dz|LIo%p6l$f_RTzz=Zp z$&R~K)dldP?qeGA977+KV8jKfVwpR6gR%}N9fEk#h}$DHgBdd1?`bnTj$)H~ln(l@ zzUuZ&sL7rc30hsD$v3Ytz-V zEF58U@XAiK+d5GinR7c@uvY|3fwMeBV0GTm;dG|86LZCkV(m*Q-6K&;?MCq9QQ!3f zE*Xr>>W(bsE>W?(=xWaY1i`%gO$@;uH0t#N_265~)Jo6BuND5#{t?#8JWh-Dnu+?u zdQX${X$2yC@AB&Bn`n)(Ez_w@??s2KsxquBEfDqJ+VtHqTwKO4{qU&iW!?Un*Wljf zPTJ!G`~|2LrnguSg0Evfj@PMSfM{nyHUr`Rs@Uork0uolz0ML0ttI#%d(T zv$l$-O&$me9=49&j7y}1$fga}Pxbbw=7pHNFdx13m9RT(36#p)~nS@8Pap1Q)8jf!umx+0t1kyqQz?~^7Hx~63oL2 z!52?o+0}2}O9v2}wdvO#-2tvVAQIC+-sKy92Mm0p=#~}k5R66qb`)I%Jucgr#>Lp%>5wu|`o>OZ zlNUjE0rMTUBoc?QNIZWTsr9-++x+jdSPw8Z2jzYB}r?vPx2+R4rbg-t!1egiijB- zAry)h@DpeYy>m)=qr^+7{*Lvzxm9DE8RUoPN&$@QT=l49uPcIGt<5Nzr$Xl&fMlKl z4(d++x3@qIyC0SZcOe4X>s2cFyRgn*AP?g|54QM#GY1O=&%qYPt-c`2ycjh5qMd+Z zY-FzI3C{-S3gZfUu@}?rL8&*#h%?gOhmR-%C1o*(dM_+BThdi*v?4`5EQXV@tH*lE zN09fhKD@5{4s}^iH6Rx| zJev$!6wMUrgvMR=8h<#NY)cdsu&cU5U7Geu*a-&1@eTtn{Xu~u1@cgmFq(6&Bg&+b zmvi%wM3B661>*R|%9TTJa0l7gFyT1PWzxAcO`Yd(SF8AzT8T41Ug9~Pznhf@SLPku@ip1t35`|)UeAW;G9_d%%j#8X?mmEWdn z+w|Q;fy{>{z*IKcYmR@_>_m!Fls0iW-ZKBI$><69N?t?{{<+Vf8zX)w3Vo?ptmpoS z%-N))7~$9@g?nE)2XSoJY<>7fsb-SaL(j0e#%l&NLTp+qQ?;|t{!(Oa$iP#A-m%RJxr4U zJoE4o`=ZZp*_Fq%^$z35f=5r@*BifiYS2%lBpHeqC_V=}Pf@C${A7$J9OvU-j#e}f z9XmFUql$1n!I7=|sK= zbaH&z8|UM!wDsx*u0kG%;snZ_axrDTnx-h#leJ11eE@^-tAWt5^~KEAfc6kS&O*%G zCH<&1&d0(ICDlid5Ln@z zhe&;fopU$$z9vWuY4CvIG$;ho9E+k1N5@LS9?zE~)oC5eq6#h_+;(J;V&n$R=ZWv! zSdWe9!F&J^iE@!ddd8E=jG#PQJR1pT+@9n*SZLkcnMiTW+M^>4%(s0Oh-wsYjkWA+ zcL-Tc<7`T4+p)-9(iqX-YIteHGM%9;tiG34sLds(7Z!;nOwoppO3i(+7uo8d$B1ZA l92;07?7zO8`|}Dn^EYpCf;g_-^}ndlxT|}oPQ@nb{{zMry^R0> diff --git a/resources/icons/32x32.png b/resources/icons/32x32.png index 3d6c68d7a6435754d713894f97da80be99d9e03c..f976294a0e62b6cbfcf4d98ed29f9680477bdda1 100644 GIT binary patch delta 1695 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 V^YPq3*8b~qcky&Sxj1aye*hRrq;miO diff --git a/resources/icons/48x48.png b/resources/icons/48x48.png index 13a3d06f97d06ebab9d2807839ec46549f0963cc..d909fb91710b89d417cc0d61ac4a25c3c8f56c3e 100644 GIT binary patch delta 2577 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 diff --git a/resources/icons/512x512.png b/resources/icons/512x512.png index 00fc47f1d3e7c6d061fe6c2617600f17b390025f..e8d189cce3a807a39f21851bebf881fc4519c20d 100644 GIT binary patch literal 30024 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 diff --git a/resources/icons/64x64.png b/resources/icons/64x64.png index de0765d92480d48f23c3c0794c10fe1586257b70..ee6b9808605067de997f59a001ef9848c95fdf26 100644 GIT binary patch delta 3357 zcmV+&4dU{WEuR{YBYzC$Nkl-1C$mZOe6H@4Jb%-WG)p?C%(k9c^I(0< z%t=Pfy>-!MxhD}ZOFP$_WBv1HFXyk80CD&hyZ{#d0KMMm0=x^ZgS+AHV4LAN_%3|< zjU+$@egbR2mcynVnFH&)rNoy=NppG-w}&^Ze}8CikX#+n%iO#(-f^`^L1uZkF41O z)fS*T{0eq}EtgH***xs*m9>Za<=A+Rjn~;}k+2xf z%+6lU-zzV`@&28i0PDM_-_OY|fxF3J3W`|2XA>@0!$1A z&$&Gs-m@rQXI~%{9fWNxPI-CL=_kNF&7A95*7@a)Ju|^=JsZr!shF9I0Qa%k|Dhgu zzkiDqx};bDE_}(@Q1Ivw(vL3qd@ku0rb;i<~thTDu27BN_+o@R}MsH4?HtWyxU;M?$a+s!j#q-Aj5ebK0rO-tm1C;{ zh+>OCNjoOsE@p4TRTk&#o!^l<(FLIYVABaw!L(25@H8JIE$#T z00ou82tYMxMg;@2M+XD^9@2VFRXAOVXsT&cIr^J!QGmJ$h@zwmpNcl1q1*m0N9owcwa_Mqs?8MxzCr|bJ})7rK}&H00iA1%04g!NqD$KK`G>v2%2k*hBfuq!_{69g-nE{BufnIA z6+k(1CBb4XLFTbcoKaK6EPrgQi$0u6tQd*EYeQ3t+te0>kXTc_6sjO~S0ZA$8%m$J zN3OWSY*zv1zb3#RORR1m{2BVN2G{!>VBl&-FWljuel+VPN#QuQA-p>I?lye_V zCtNzjGL#NI)30rD5lx;TRG9jI3R;@&St7FH1R2D|2?S|Z0S1-`;D6UIvdno5Tfa|g z&6v@_;QQF<1^+&?^E4LpPu-z)URv8nK+}S^A_Svn10M8?ieMB7DxT?nA{`+LIN6_>bP#JM$b zPL{nw&Co^U*aMoyQFU3mDf}}_B+uJD2;rL!bNZ7eycZK**16u^8Lzd&u?SCyR^vny z+!hP_6{b=t-~9kv83BeNz+ZE9^6!Dn8;L)S#G!gi7wFbGQh%1nT0QGrpp%}>qW5a? zvIHGKT(EN}HP>`aIpti7j2o=?x-Yqe?;h*uH&CN>ZC3Tl*yi1KL1{DO%zLsI$yIgT6OUTjM+xDY5GFHr16Cn1h#may zW(dx&WIbcOj(>7eZLqA&;XYT;0ubR6_8`k1h?U`PC4svo0ZM&VpS2;~S^c)apB41W zGb_l+8vOk3e)n7FCo^%+91$_l;sPozKobI}6~@g57NV_*?70B!Idvp=?=(a1&N`EQ zIx8U*QMKVL0u&D?awnUlc?edh4LcK53iVB}(2fGYC4VaHU|<=?F7SJSBVeIbzjXmD z;AREX2yxd5Di$c%Y?ej16q9^gBVxt|X|=;aHbKYxE#QqE=skw?I#r1TPlH zfO2xX?tkDYZ&sjQor!C4i!6)qJ42;U#R<6#bwJGJ|H&?WR#AY0H$7o7B1dI7Xk;+3 zmJIMxxslwT6)1&{Kn$h9`HgjUpLzfZ;Z2Vu#wOnPdjd&_CeN)3)e=AtMhI$dRJ^UfLS{p^j3Jk+em;Ze^TYVdjMZ>Lk$irxP6sL#YN2JhhgW5Byg#!Gxzbm4!(p$& zWI(C-;59>46kuX7_;K$1_fHJSFVG#5sro$t&5<9;DScC}oWwO;7MP-7Apl5^Qswn`AyGlL4eRVOnY2Bf{bWMIFmlzTP3(+)~#i#8oH59I{ z4%(?2MMuh;6)61;_lhGPQ4td|3{PU!wE!-B4)yYWx+6AKV`s83rm44Spo=k+%E1ahEJbt+eWbA#PTHMYQ4?Vum+vceOyI$^yXN(nzj=rxI4()N{F{ z+&~*9#~1Ygyeroq>YH~n1WkPq((AW132J)}P;~){?@$3;cNcK80!?%123=+69)FB% zDw-9z=ZIL#ZiL{^*4$rwD07T1GV?<91!#LSGir7!Eza_~@lMJ`CT4rC{4SD?kXDIK`1>1u?g2 zS;RiC2iV{ROde2BRaU$dz8DOQ1`GS(kB|UUie6gd0(hu=A&Q?DhCKHOXjZ^*l>!R) zP=?^Cj0>7~Ml0S7T@MC+27kYU&$P5Zy_CzDFcV&Eix4L(Ohgd3D`2X^#4U~w$kPl( zE8PxX1P0y-MpQ}wpA`HF4zx8+c+c-uVbT}{Rchk`Q9JSV4@6$^{;fZ}6^v*v0eoay zzky9{&K#;RL(U?+8l&tqI1_9U$6;z|VCyIWd_DkQfO&0=6K$vjV}D?ZuCWV#0TD2w zngaN|6|RFv!B!YQg5L&X;iHvWw_e(VzQt4K3XlYUf^Ed}YOW|$p zwMM;k1OfOiY%OEce}f^=y(8A9myYB^@HJQrwuC+Kdx&=An)FgF&VkkNcdzXJR5jM2 nmuk`nuBql4jJ5Vn%@%(FN7XzaR7P7}00000NkvXXu0mjfLu^g{ literal 5778 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 literal 7910 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 DCJikk From 02450eb4743d9c1f552a1a10f89771ccef10f6df Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 31 Oct 2023 14:28:15 +0100 Subject: [PATCH 06/96] updates for existing tree view tests --- .../KeyTreeSettings/KeyTreeSettings.tsx | 1 + tests/e2e/common-actions/browser-actions.ts | 52 +++++++- tests/e2e/helpers/keys.ts | 19 +-- tests/e2e/pageObjects/browser-page.ts | 116 ++---------------- .../pageObjects/components/browser/index.ts | 4 +- .../components/browser/tree-view.ts | 83 +++++++++++++ .../critical-path/browser/bulk-upload.e2e.ts | 4 +- .../tree-view/tree-view-improvements.e2e.ts | 97 ++++++++------- .../critical-path/browser/bulk-upload.e2e.ts | 4 +- .../web/critical-path/browser/context.e2e.ts | 17 +-- .../browser/search-capabilities.e2e.ts | 20 ++- .../database-overview/database-index.e2e.ts | 20 +-- .../memory-efficiency.e2e.ts | 6 +- .../critical-path/tree-view/delimiter.e2e.ts | 19 +-- .../tree-view/tree-view-improvements.e2e.ts | 116 +++++++++--------- .../critical-path/tree-view/tree-view.e2e.ts | 29 +---- .../web/regression/browser/filtering.e2e.ts | 4 +- .../web/regression/tree-view/tree-view.e2e.ts | 28 +++-- .../workbench/import-tutorials.e2e.ts | 4 +- 19 files changed, 327 insertions(+), 316 deletions(-) create mode 100644 tests/e2e/pageObjects/components/browser/tree-view.ts diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx index 95d401b0c0..acc3011a46 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeSettings/KeyTreeSettings.tsx @@ -151,6 +151,7 @@ const KeyTreeSettings = ({ loading }: Props) => { Cancel diff --git a/tests/e2e/common-actions/browser-actions.ts b/tests/e2e/common-actions/browser-actions.ts index 4eb63aacc5..a0afa741ae 100644 --- a/tests/e2e/common-actions/browser-actions.ts +++ b/tests/e2e/common-actions/browser-actions.ts @@ -1,4 +1,4 @@ -import {Selector, t} from 'testcafe'; +import { Selector, t } from 'testcafe'; import { BrowserPage } from '../pageObjects'; const browserPage = new BrowserPage(); @@ -29,6 +29,7 @@ export class BrowserActions { } } } + /** * Verify tooltip contains text * @param expectedText Expected link that is compared with actual @@ -39,17 +40,62 @@ export class BrowserActions { ? await t.expect(browserPage.tooltip.textContent).contains(expectedText, `"${expectedText}" Text is incorrect in tooltip`) : await t.expect(browserPage.tooltip.textContent).notContains(expectedText, `Tooltip still contains text "${expectedText}"`); } + /** * Verify that the new key is displayed at the top of the list of keys and opened and pre-selected in List view - * */ + * @param keyName Key name + */ async verifyKeyDisplayedTopAndOpened(keyName: string): Promise { await t.expect(Selector('[aria-rowindex="1"]').withText(keyName).visible).ok(`element with ${keyName} is not visible in the top of list`); await t.expect(browserPage.keyNameFormDetails.withText(keyName).visible).ok(`element with ${keyName} is not opened`); } + /** * Verify that the new key is not displayed at the top of the list of keys and opened and pre-selected in List view - * */ + * @param keyName Key name + */ async verifyKeyIsNotDisplayedTop(keyName: string): Promise { await t.expect(Selector('[aria-rowindex="1"]').withText(keyName).exists).notOk(`element with ${keyName} is not visible in the top of list`); } + + /** + * Check tree view structure + * @folders name of folders for tree view build + * @delimiter string with delimiter value + * @commonKeyFolder flag if not patterned keys will be displayed + */ + async checkTreeViewFoldersStructure(folders: string[][], delimiter: string, commonKeyFolder: boolean): Promise { + // Verify that all keys that are not inside of tree view doesn't contain delimiter + if (commonKeyFolder) { + const notPatternedKeys = Selector('[data-testid^="badge"]').parent('[data-testid^="node-item_"]'); + const notPatternedKeysNumber = await notPatternedKeys.count; + for (let i = 0; i < notPatternedKeysNumber; i++) { + await t.expect(notPatternedKeys.nth(i).withText(delimiter).exists).notOk('Not contained delimiter keys'); + } + } + // Verify that every level of tree view is clickable + const foldersNumber = folders.length; + for (let i = 0; i < foldersNumber; i++) { + const innerFoldersNumber = folders[i].length; + const array: string[] = []; + for (let j = 0; j < innerFoldersNumber; j++) { + if (j === 0) { + const folderSelector = `[data-testid="node-item_${folders[i][j]}${delimiter}"]`; + array.push(folderSelector); + await t.click(Selector(folderSelector)); + } + else { + const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); + const folderSelector = `${lastSelector}${folders[i][j]}${delimiter}"]`; + array.push(folderSelector); + await t.click(Selector(folderSelector)); + } + } + // Verify that the last folder level contains required keys + const foundKeyName = `${folders[i].join(delimiter)}`; + await t + .expect(Selector(`[data-testid*="key-${foundKeyName}"]`).exists).ok('Specific key not found') + .click(array[0]); + } + } } diff --git a/tests/e2e/helpers/keys.ts b/tests/e2e/helpers/keys.ts index 517d45a6f2..8e1fa94393 100644 --- a/tests/e2e/helpers/keys.ts +++ b/tests/e2e/helpers/keys.ts @@ -249,21 +249,13 @@ export async function deleteAllKeysFromDB(host: string, port: string): Promise { +export async function verifyKeysDisplayingInTheList(keyNames: string[], isDisplayed: boolean): Promise { for (const keyName of keyNames) { - await t.expect(browserPage.getKeySelectorByName(keyName).exists).ok(`The key ${keyName} not found`); - } -} - -/** -* Verifying if the Keys are not in the List of keys -* @param keyNames The names of the keys -*/ - -export async function verifyKeysNotDisplayedInTheList(keyNames: string[]): Promise { - for (const keyName of keyNames) { - await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).notOk(`The key ${keyName} found`); + isDisplayed + ? await t.expect(browserPage.getKeySelectorByName(keyName).exists).ok(`The key ${keyName} not found`) + : await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).notOk(`The key ${keyName} found`); } } @@ -271,7 +263,6 @@ export async function verifyKeysNotDisplayedInTheList(keyNames: string[]): Promi * Verify search/filter value * @param value The value in search/filter input */ - export async function verifySearchFilterValue(value: string): Promise { await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', value).exists).ok(`Filter per key name ${value} is not applied/correct`); } diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 141888d2c0..558bdd890a 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -1,10 +1,11 @@ import { t, Selector } from 'testcafe'; import { Common } from '../helpers/common'; import { InstancePage } from './instance-page'; -import { BulkActions } from './components/browser'; +import { BulkActions, TreeView } from './components/browser'; export class BrowserPage extends InstancePage { BulkActions = new BulkActions(); + TreeView = new TreeView(); //CSS Selectors cssSelectorGrid = '[aria-label="grid"]'; @@ -71,13 +72,9 @@ export class BrowserPage extends InstancePage { databaseInfoIcon = Selector('[data-testid=db-info-icon]'); treeViewButton = Selector('[data-testid=view-type-list-btn]'); browserViewButton = Selector('[data-testid=view-type-browser-btn]'); - treeViewSeparator = Selector('[data-testid=tree-view-delimiter-btn]'); searchButton = Selector('[data-testid=search-btn]'); clearFilterButton = Selector('[data-testid=reset-filter-btn]'); clearSelectionButton = Selector('[data-testid=clear-selection-btn]'); - treeViewDelimiterButton = Selector('[data-testid=tree-view-delimiter-btn]'); - treeViewDelimiterValueSave = Selector('[data-testid=apply-btn]'); - treeViewDelimiterValueCancel = Selector('[data-testid=cancel-btn]'); fullScreenModeButton = Selector('[data-testid=toggle-full-screen]'); closeRightPanel = Selector('[data-testid=close-right-panel-btn]'); addNewStreamEntry = Selector('[data-testid=add-key-value-items-btn]'); @@ -177,7 +174,6 @@ export class BrowserPage extends InstancePage { jsonKeyInput = Selector('[data-testid=json-key]'); jsonValueInput = Selector('[data-testid=json-value]'); countInput = Selector('[data-testid=count-input]'); - treeViewDelimiterInput = Selector('[data-testid=tree-view-delimiter-input]'); streamEntryId = Selector('[data-testid=entryId]'); streamField = Selector('[data-testid=field-name]'); streamValue = Selector('[data-testid=field-value]'); @@ -211,28 +207,19 @@ export class BrowserPage extends InstancePage { jsonKeyValue = Selector('[data-testid=json-data]'); jsonError = Selector('[data-testid=edit-json-error]'); tooltip = Selector('[role=tooltip]'); - noResultsFound = Selector('[data-test-subj=no-result-found]'); + noResultsFound = Selector('[data-testid=no-result-found-only]'); searchAdvices = Selector('[data-test-subj=search-advices]'); keysNumberOfResults = Selector('[data-testid=keys-number-of-results]'); keysTotalNumber = Selector('[data-testid=keys-total]'); overviewConnectedClients = Selector('[data-test-subj=overview-connected-clients]'); overviewCommandsSec = Selector('[data-test-subj=overview-commands-sec]'); overviewCpu = Selector('[data-test-subj=overview-cpu]'); - treeViewArea = Selector('[data-test-subj=tree-view-panel]'); scannedValue = Selector('[data-testid=keys-number-of-scanned]'); - treeViewKeysNumber = Selector('[data-testid^=count_]'); - treeViewPercentage = Selector('[data-testid^=percentage_]'); - treeViewFolders = Selector('[data-test-subj^=node-arrow-icon_]'); totalKeysNumber = Selector('[data-testid=keys-total]'); databaseInfoToolTip = Selector('[data-testid=db-info-tooltip]'); - treeViewDeviceFolder = Selector('[data-testid^=node-item_device] div'); - treeViewDeviceKyesCount = Selector('[data-testid^=count_device] span'); ttlValueInKeysTable = Selector('[data-testid^=ttl-]'); stringKeyValue = Selector('.key-details-body pre'); keyDetailsBadge = Selector('.key-details-header .euiBadge__text'); - treeViewKeysItem = Selector('[data-testid*="keys:keys:"]'); - treeViewNotPatternedKeys = Selector('[data-testid*="node-item_keys"]'); - treeViewNodeArrowIcon = Selector('[data-test-subj^=node-arrow-icon_]'); modulesTypeDetails = Selector('[data-testid=modules-type-details]'); filteringLabel = Selector('[data-testid^=badge-]'); keysSummary = Selector('[data-testid=keys-summary]'); @@ -590,6 +577,7 @@ export class BrowserPage extends InstancePage { /** * Delete Key By name after Hovering + * @param keyName The name of the key */ async deleteKeyByNameFromList(keyName: string): Promise { await this.searchByKeyName(keyName); @@ -766,7 +754,10 @@ export class BrowserPage extends InstancePage { .click(this.saveMemberButton); } - //Open key details + /** + * Open key details with search + * @param keyName The name of the key + */ async openKeyDetails(keyName: string): Promise { await this.searchByKeyName(keyName); await t.click(this.keyNameInTheList); @@ -878,66 +869,6 @@ export class BrowserPage extends InstancePage { await t.typeText(this.jsonValueInput, jsonStructure, { replace: true, paste: true }); await t.click(this.applyEditButton); } - /** - * Check tree view structure - * @folders name of folders for tree view build - * @delimiter string with delimiter value - * @commonKeyFolder flag if not patterned keys will be displayed - */ - async checkTreeViewFoldersStructure(folders: string[][], delimiter: string, commonKeyFolder: boolean): Promise { - // Verify that all keys that are not inside of tree view doesn't contain delimiter - if (commonKeyFolder) { - await t - .expect(this.treeViewNotPatternedKeys.exists).ok('Folder with not patterned keys') - .click(this.treeViewNotPatternedKeys); - const notPatternedKeys = Selector('[data-test-subj=key-list-panel]').find(this.cssSelectorKey); - const notPatternedKeysNumber = await notPatternedKeys.count; - for (let i = 0; i < notPatternedKeysNumber; i++) { - await t.expect(notPatternedKeys.nth(i).withText(delimiter).exists).notOk('Not contained delimiter keys'); - } - } - // Verify that every level of tree view is clickable - const foldersNumber = folders.length; - for (let i = 0; i < foldersNumber; i++) { - const innerFoldersNumber = folders[i].length; - const array: string[] = []; - for (let j = 0; j < innerFoldersNumber; j++) { - if (j === 0) { - const folderSelector = `[data-testid="node-item_${folders[i][j]}${delimiter}"]`; - array.push(folderSelector); - await t.click(Selector(folderSelector)); - } - else { - const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); - const folderSelector = `${lastSelector}${folders[i][j]}${delimiter}"]`; - array.push(folderSelector); - await t.click(Selector(folderSelector)); - } - } - // Verify that the last folder level contains required keys - const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); - const folderSelector = `${lastSelector}keys${delimiter}keys${delimiter}"]`; - await t.click(Selector(folderSelector)); - const foundKeyName = `${folders[i].join(delimiter)}`; - await t - .expect(Selector(`[data-testid*="key-${foundKeyName}"]`).exists).ok('Specific key not found') - .click(array[0]); - } - } - - /** - * Change delimiter value - * @delimiter string with delimiter value - */ - async changeDelimiterInTreeView(delimiter: string): Promise { - // Open delimiter popup - await t.click(this.treeViewDelimiterButton); - // Apply new value to the field - await t.typeText(this.treeViewDelimiterInput, delimiter, { replace: true, paste: true }); - // Click on save button - await t.click(this.treeViewDelimiterValueSave); - await t.expect(this.treeViewDelimiterButton.withExactText(delimiter).exists).ok('Delimiter is not changed'); - } //Delete entry from Stream key async deleteStreamEntry(): Promise { @@ -1019,33 +950,6 @@ export class BrowserPage extends InstancePage { .click(option); } - /** - * Get text from first tree element - */ - async getTextFromNthTreeElement(number: number): Promise { - return (await Selector('[role="treeitem"]').nth(number).find('div').textContent).replace(/\s/g, ''); - } - - /** - * Open tree folder with multiple level - * @param names folder names with sequence of subfolder - */ - async openTreeFolders(names: string[]): Promise { - let base = `node-item_${names[0]}:`; - await t.click(Selector(`[data-testid="${base}"]`)); - if (names.length > 1) { - for (let i = 1; i < names.length; i++) { - base = `${base }${names[i]}:`; - await t.click(Selector(`[data-testid="${base}"]`)); - } - } - await t.click(Selector(`[data-testid="${base}keys:keys:"]`)); - - await t.expect( - Selector(`[data-testid="${base}keys:keys:"]`).visible) - .ok('Folder is not selected'); - } - /** * Verify that database has no keys */ @@ -1061,6 +965,10 @@ export class BrowserPage extends InstancePage { await t.click(this.clearFilterButton); } + /** + * Open Guide link by name + * @param guide The guide name + */ async clickGuideLinksByName(guide: string): Promise { const linkGuide = Selector(`[data-testid="guide-button-${guide}"]`); await t.click(linkGuide); diff --git a/tests/e2e/pageObjects/components/browser/index.ts b/tests/e2e/pageObjects/components/browser/index.ts index 8a8463c193..79bba1bb76 100644 --- a/tests/e2e/pageObjects/components/browser/index.ts +++ b/tests/e2e/pageObjects/components/browser/index.ts @@ -1,5 +1,7 @@ import { BulkActions } from './bulk-actions'; +import { TreeView } from './tree-view'; export { - BulkActions + BulkActions, + TreeView, }; diff --git a/tests/e2e/pageObjects/components/browser/tree-view.ts b/tests/e2e/pageObjects/components/browser/tree-view.ts new file mode 100644 index 0000000000..0472b24f6b --- /dev/null +++ b/tests/e2e/pageObjects/components/browser/tree-view.ts @@ -0,0 +1,83 @@ +import { Selector, t } from 'testcafe'; + +export class TreeView { + //------------------------------------------------------------------------------------------- + //DECLARATION OF SELECTORS + //*Declare all elements/components of the relevant page. + //*Target any element/component via data-id, if possible! + //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). + //------------------------------------------------------------------------------------------- + //BUTTONS + treeViewSettingsBtn = Selector('[data-testid=tree-view-settings-btn]'); + treeViewDelimiterValueSave = Selector('[data-testid=tree-view-apply-btn]'); + treeViewDelimiterValueCancel = Selector('[data-testid=tree-view-cancel-btn]'); + // TEXT ELEMENTS + treeViewKeysNumber = Selector('[data-testid^=count_]'); + treeViewDeviceFolder = Selector('[data-testid^=node-item_device] div'); + //INPUTS + treeViewDelimiterInput = Selector('[data-testid=tree-view-delimiter-input]'); + + + /** + * Get folder selector by folder name + * @param folderName The name of the folder + */ + getFolderSelectorByName(folderName: string): Selector { + return Selector(`[data-testid^="node-item_${folderName}"]`); + } + + /** + * Get folder counter selector by folder name + * @param folderName The name of the folder + */ + getFolderCountSelectorByName(folderName: string): Selector { + return Selector(`[data-testid^="count_${folderName}"]`); + } + + /** + * Verifying if the Keys are in the List of keys + * @param keyNames The names of the keys + * @param isDisplayed True if keys should be displayed + */ + async verifyFolderDisplayingInTheList(folderName: string, isDisplayed: boolean): Promise { + isDisplayed + ? await t.expect(this.getFolderSelectorByName(folderName).exists).ok(`The folder ${folderName} not found`) + : await t.expect(this.getFolderSelectorByName(folderName).exists).notOk(`The folder ${folderName} found`); + } + + /** + * Change delimiter value + * @delimiter string with delimiter value + */ + async changeDelimiterInTreeView(delimiter: string): Promise { + // Open delimiter popup + await t.click(this.treeViewSettingsBtn); + // Apply new value to the field + await t.typeText(this.treeViewDelimiterInput, delimiter, { replace: true, paste: true }); + // Click on save button + await t.click(this.treeViewDelimiterValueSave); + } + + /** + * Get text from tree element by number + * @param number The number of tree folder + */ + async getTextFromNthTreeElement(number: number): Promise { + return (await Selector('[role="treeitem"]').nth(number).find('div').textContent).replace(/\s/g, ''); + } + + /** + * Open tree folder with multiple level + * @param names folder names with sequence of subfolder + */ + async openTreeFolders(names: string[]): Promise { + let base = `node-item_${names[0]}:`; + await t.click(Selector(`[data-testid="${base}"]`)); + if (names.length > 1) { + for (let i = 1; i < names.length; i++) { + base = `${base }${names[i]}:`; + await t.click(Selector(`[data-testid="${base}"]`)); + } + } + } +} diff --git a/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts index a86c1d2706..fb6d8202b9 100644 --- a/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts @@ -5,7 +5,7 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -import { deleteAllKeysFromDB, verifyKeysDisplayedInTheList } from '../../../../helpers/keys'; +import { deleteAllKeysFromDB, verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); @@ -53,7 +53,7 @@ test('Verify bulk upload of different text docs formats', async t => { await browserPage.BulkActions.uploadFileInBulk(filePathes.allKeysFile); await verifyCompletedResultText(allKeysResults); await browserPage.searchByKeyName('*key1'); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); // Verify that Upload button disabled after starting new upload await t.click(browserPage.BulkActions.bulkActionStartNewButton); diff --git a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts index f3db07e435..4a5126f300 100644 --- a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -5,7 +5,7 @@ import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { KeyTypesTexts, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; -import { verifyKeysDisplayedInTheList, verifyKeysNotDisplayedInTheList } from '../../../../helpers/keys'; +import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; const browserPage = new BrowserPage(); @@ -27,12 +27,11 @@ test await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); }) .after(async() => { - await t.click(browserPage.patternModeBtn); - await browserPage.deleteKeysByNames(keyNames); + await browserPage.Cli.sendCommandInCli('flushdb'); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Tree view preselected folder', async t => { - keyName1 = Common.generateWord(10); // used to create index name - keyName2 = Common.generateWord(10); // used to create index name + keyName1 = Common.generateWord(10); + keyName2 = Common.generateWord(10); keyNameSingle = Common.generateWord(10); keyNames = [`${keyName1}:1`, `${keyName1}:2`, `${keyName2}:1`, `${keyName2}:2`, keyNameSingle]; @@ -41,7 +40,7 @@ test `HSET ${keyNames[0]} field value`, `HSET ${keyNames[1]} field value`, `HSET ${keyNames[2]} field value`, - `HSET ${keyNames[3]} field value`, + `SADD ${keyNames[3]} value`, `SADD ${keyNames[4]} value` ]; @@ -49,17 +48,17 @@ test await browserPage.Cli.sendCommandsInCli(commands); await t.click(browserPage.treeViewButton); // The folder without any patterns selected and the list of keys is displayed when there is a folder without any patterns - await verifyKeysDisplayedInTheList([keyNameSingle]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); - await browserPage.openTreeFolders([await browserPage.getTextFromNthTreeElement(1)]); + await browserPage.TreeView.openTreeFolders([await browserPage.TreeView.getTextFromNthTreeElement(1)]); await browserPage.selectFilterGroupType(KeyTypesTexts.Set); // The folder without any namespaces is selected (if exists) when folder does not exist after search/filter - await verifyKeysDisplayedInTheList([keyNameSingle]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); await browserPage.setAllKeyType(); // The folder without any patterns selected and the list of keys is displayed when there is a folder without any patterns - await verifyKeysDisplayedInTheList([keyNameSingle]); - await verifyKeysNotDisplayedInTheList([`${keyNames[0]}:1`, `${keyNames[2]}:2`]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); + await verifyKeysDisplayingInTheList([`${keyNames[0]}:1`, `${keyNames[2]}:2`], false); // switch between browser view and tree view await t.click(browserPage.browserViewButton) @@ -67,51 +66,51 @@ test await browserPage.deleteKeyByName(keyNames[4]); await t.click(browserPage.clearFilterButton); // get first folder name - const firstTreeItemText = await browserPage.getTextFromNthTreeElement(0); - const firstTreeItemKeys = Selector(`[data-testid="node-item_${firstTreeItemText}:keys:keys:"]`); // keys after node item opened - // The first folder with namespaces is expanded and selected when there is no folder without any patterns - await t.expect(firstTreeItemKeys.visible) - .ok('First folder is not expanded'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + const firstTreeItemText = await browserPage.TreeView.getTextFromNthTreeElement(0); + // All folders with namespaces are collapsed when there is no folder without any patterns + await verifyKeysDisplayingInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`], false); const commands1 = [ - `HSET ${keyNames[4]} field value` + `SADD ${keyNames[4]} value` ]; await browserPage.Cli.sendCommandsInCli(commands1); await t.click(browserPage.refreshKeysButton); - // Refreshed Tree view preselected folder - await t.expect(firstTreeItemKeys.visible) - .ok('Folder is not selected'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + // Folders are collapsed after refresh + await verifyKeysDisplayingInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`], false); + await verifyKeysDisplayingInTheList([keyNameSingle], true); await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); - await t.expect(firstTreeItemKeys.visible).ok('Folder is not selected after searching with HASH'); - // Filtered Tree view preselected folder - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + // Only folders according to key type filter are displayed + await verifyKeysDisplayingInTheList([keyNameSingle], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(firstTreeItemText, true); - await browserPage.searchByKeyName('*'); - // Search capability Filtered Tree view preselected folder - await t.expect(firstTreeItemKeys.visible).ok('Folder is not selected'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + await browserPage.searchByKeyName(`${keyName1}*`); + // Only folders according to filter by key names are displayed + await verifyKeysDisplayingInTheList([keyNameSingle], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); await t.click(browserPage.clearFilterButton); - // Filtered Tree view preselected folder - await t.expect(firstTreeItemKeys.visible).ok('Folder is not selected'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + // All folders are displayed and collapsed after cleared filter + await verifyKeysDisplayingInTheList([keyNameSingle], true); + await verifyKeysDisplayingInTheList([keyName1], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); await browserPage.selectFilterGroupType(KeyTypesTexts.Stream); - // Filtered Tree view preselected folder - await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Key is not found message not displayed'); + // Verify that No results found message is displayed in case of invalid filtering + await t.expect(browserPage.noResultsFound.textContent).contains('No results found.', 'Key is not found message not displayed'); await browserPage.setAllKeyType(); // clear stream from filter - // Filtered Tree view preselected folder - await t.expect(browserPage.keyListTable.textContent).notContains('No results found.', 'Key is not found message still displayed'); - await t.expect( - firstTreeItemKeys.exists) - .notOk('First folder is expanded'); + // Verify that no results found message not displayed after clearing filter + await t.expect(browserPage.noResultsFound.exists).notOk('Key is not found message still displayed'); + // All folders are displayed and collapsed after cleared filter + await verifyKeysDisplayingInTheList([keyNameSingle], true); + await verifyKeysDisplayingInTheList([keyName1], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); }); - test .before(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); @@ -140,7 +139,7 @@ test await browserPage.selectIndexByName(index); await t.click(browserPage.treeViewButton); await t.click(Selector(`[data-testid="${`node-item_${folders[0]}:`}"]`)); // close folder - await browserPage.openTreeFolders(folders); + await browserPage.TreeView.openTreeFolders(folders); await t.click(browserPage.refreshKeysButton); // Refreshed Tree view preselected folder for index based search await t.expect( @@ -174,24 +173,24 @@ test await browserPage.Cli.sendCommandsInCli(commands); await t.click(browserPage.treeViewButton); // The folder without any patterns selected and the list of keys is displayed when there is a folder without any patterns - await verifyKeysDisplayedInTheList([keyNameSingle]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); - await browserPage.openTreeFolders([keyName1]); // Type: hash - await browserPage.openTreeFolders([keyName2]); // Type: list + await browserPage.TreeView.openTreeFolders([keyName1]); // Type: hash + await browserPage.TreeView.openTreeFolders([keyName2]); // Type: list await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); // The first folder with namespaces is expanded and selected when folder and folder without any namespaces does not exist after search/filter - await verifyKeysDisplayedInTheList([keyNames[0], keyNames[1]]); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[1]], true); await browserPage.setAllKeyType(); await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames[0]}`]); await t.click(browserPage.refreshKeysButton); // refresh keys // The previously selected folder is preselected when key does not exist after keys refresh - await verifyKeysDisplayedInTheList([keyNames[1]]); - await verifyKeysNotDisplayedInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]]); + await verifyKeysDisplayingInTheList([keyNames[1]], true); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]], false); await browserPage.searchByKeyName('*'); await t.click(browserPage.refreshKeysButton); // Search capability Refreshed Tree view preselected folder - await verifyKeysDisplayedInTheList([keyNames[1]]); - await verifyKeysNotDisplayedInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]]); + await verifyKeysDisplayingInTheList([keyNames[1]], true); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]], false); }); diff --git a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts index 1509482f92..b188a5f811 100644 --- a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts @@ -5,7 +5,7 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -import { deleteAllKeysFromDB, verifyKeysDisplayedInTheList } from '../../../../helpers/keys'; +import { deleteAllKeysFromDB, verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); @@ -53,7 +53,7 @@ test('Verify bulk upload of different text docs formats', async t => { await browserPage.BulkActions.uploadFileInBulk(filePathes.allKeysFile); await verifyCompletedResultText(allKeysResults); await browserPage.searchByKeyName('*key1'); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); // Verify that Upload button disabled after starting new upload await t.click(browserPage.BulkActions.bulkActionStartNewButton); diff --git a/tests/e2e/tests/web/critical-path/browser/context.e2e.ts b/tests/e2e/tests/web/critical-path/browser/context.e2e.ts index 9125b4a017..98be742867 100644 --- a/tests/e2e/tests/web/critical-path/browser/context.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/context.e2e.ts @@ -100,12 +100,15 @@ test const scrollY = 1000; await t.scroll(browserPage.cssSelectorGrid, 0, scrollY); - const keysCount = await browserPage.virtualTableContainer.find(browserPage.cssVirtualTableRow).count; - const targetKey = browserPage.virtualTableContainer.find(browserPage.cssVirtualTableRow).nth(Math.floor(keysCount / 2)); + const virtualizedTableKeyIndex = browserPage.virtualTableContainer.find(browserPage.cssVirtualTableRow).nth(10); + const targetKeyIndex = await virtualizedTableKeyIndex.getAttribute('aria-rowindex'); + const targetKey = browserPage.virtualTableContainer.find(`[aria-rowindex="${targetKeyIndex}"`); const targetKeyName = await targetKey.find(browserPage.cssSelectorKey).innerText; + // Open key details await t.click(targetKey); - await t.expect(await targetKey.getAttribute('class')).contains('table-row-selected', 'Not correct key selected in key list'); + // Verify that key selected + await t.expect(targetKey.getAttribute('class')).contains('table-row-selected', 'Not correct key selected in key list'); await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); // Return back to Browser and check key details selected @@ -113,7 +116,7 @@ test // Check Keys details saved await t.expect(browserPage.keyNameFormDetails.innerText).eql(targetKeyName, 'Key details is not saved as context'); // Check Key selected in Key List - await t.expect(await targetKey.getAttribute('class')).contains('table-row-selected', 'Not correct key selected in key list'); + await t.expect(targetKey.getAttribute('class')).contains('table-row-selected', 'Not correct key selected in key list'); }); test .after(async() => { @@ -132,13 +135,13 @@ test await t.click(browserPage.Cli.cliCollapseButton); await t.click(browserPage.refreshKeysButton); - const keyList = await browserPage.keyListTable; - const keyListSGrid = await keyList.find(browserPage.cssSelectorGrid); + const keyList = browserPage.keyListTable; + const keyListSGrid = keyList.find(browserPage.cssSelectorGrid); // Scroll key list await t.scroll(keyListSGrid, 0, scrollY); // Find any key from list that is visible - const renderedRows = await keyList.find(browserPage.cssSelectorRows); + const renderedRows = keyList.find(browserPage.cssSelectorRows); const renderedRowsCount = await renderedRows.count; const randomKey = renderedRows.nth(Math.floor((Math.random() * renderedRowsCount))); const randomKeyName = await randomKey.find(browserPage.cssSelectorKey).textContent; diff --git a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts index ac7d0b952d..75e76fceae 100644 --- a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts @@ -10,7 +10,7 @@ import { import { rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; -import { verifyKeysDisplayedInTheList, verifyKeysNotDisplayedInTheList } from '../../../../helpers/keys'; +import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; const browserPage = new BrowserPage(); @@ -81,7 +81,7 @@ test // Verify that user can search by index in Browser view await browserPage.selectIndexByName(indexName); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); await t.expect(browserPage.getKeySelectorByName(keyName).exists).notOk('Key without index displayed after search'); // Verify that user can search by index plus key value await browserPage.searchByKeyName('Hall School'); @@ -107,15 +107,15 @@ test // Verify that user can search by index in Tree view await t.click(browserPage.treeViewButton); // Change delimiter - await browserPage.changeDelimiterInTreeView('-'); + await browserPage.TreeView.changeDelimiterInTreeView('-'); await browserPage.selectIndexByName(indexName); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); await t.expect(browserPage.getKeySelectorByName(keyName).exists).notOk('Key without index displayed after search'); // Verify that user see the database scanned when he switch to Pattern search mode await t.click(browserPage.patternModeBtn); await t.click(browserPage.browserViewButton); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); await t.expect(browserPage.getKeySelectorByName(keyName).exists).ok('Database not scanned after returning to Pattern search mode'); }); test @@ -273,9 +273,7 @@ test await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneBigConfig); })('Verify that indexed keys from previous DB are NOT displayed when user connects to another DB', async t => { - /* - Link to ticket: https://redislabs.atlassian.net/browse/RI-3863 - */ + // Link to ticket: https://redislabs.atlassian.net/browse/RI-3863 // key names to validate in the standalone database keyNames = [`${keyNameSimpleDb}:1`, `${keyNameSimpleDb}:2`, `${keyNameSimpleDb}:3`, `${keyNameSimpleDb}:4`, `${keyNameSimpleDb}:5`]; @@ -308,14 +306,14 @@ test await t.click(browserPage.treeViewButton); // switch to tree view await t.click(browserPage.redisearchModeBtn); // click redisearch button await browserPage.selectIndexByName(indexNameSimpleDb); // select pre-created index in the standalone database - await browserPage.changeDelimiterInTreeView('-'); // change delimiter in tree view to be able to verify keys easily + await browserPage.TreeView.changeDelimiterInTreeView('-'); // change delimiter in tree view to be able to verify keys easily - await verifyKeysDisplayedInTheList(keyNames); // verify created keys are visible + await verifyKeysDisplayingInTheList(keyNames, true); // verify created keys are visible await t.click(browserPage.OverviewPanel.myRedisDBLink); // go back to database selection page await myRedisDatabasePage.clickOnDBByName(bigDbName); // click database name from ossStandaloneBigConfig.databaseName - await verifyKeysNotDisplayedInTheList(keyNames); // Verify that standandalone database keys are NOT visible + await verifyKeysDisplayingInTheList(keyNames, false); // Verify that standandalone database keys are NOT visible await t.expect(Selector('span').withText('Select Index').exists).ok('Index is still selected'); }); diff --git a/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts b/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts index 033cb5a237..10cd1d4f2d 100644 --- a/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts @@ -9,7 +9,7 @@ import { } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -import { verifyKeysDisplayedInTheList, verifyKeysNotDisplayedInTheList, verifySearchFilterValue } from '../../../../helpers/keys'; +import { verifyKeysDisplayingInTheList, verifySearchFilterValue } from '../../../../helpers/keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -70,8 +70,8 @@ test('Switching between indexed databases', async t => { await browserPage.addHashKey(keyNameForSearchInLogicalDb); // Verify that data changed for indexed db on Tree view await t.click(browserPage.treeViewButton); - await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb]); - await verifyKeysNotDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList([keyNameForSearchInLogicalDb], true); + await verifyKeysDisplayingInTheList(keyNames, false); // Filter by Hash keys and search by key name await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); @@ -81,25 +81,25 @@ test('Switching between indexed databases', async t => { // Verify that search/filter saved after switching index in Browser await verifySearchFilterValue(keyNameForSearchInLogicalDb); - await verifyKeysNotDisplayedInTheList([keyNameForSearchInLogicalDb]); + await verifyKeysDisplayingInTheList([keyNameForSearchInLogicalDb], false); await t.click(browserPage.browserViewButton); // Change index to logical db await browserPage.OverviewPanel.changeDbIndex(1); await verifySearchFilterValue(keyNameForSearchInLogicalDb); - await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb]); + await verifyKeysDisplayingInTheList([keyNameForSearchInLogicalDb], true); // Return to default database and open search capability await browserPage.OverviewPanel.changeDbIndex(0); await t.click(browserPage.redisearchModeBtn); await browserPage.selectIndexByName(indexName); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); // Change index to logical db await browserPage.OverviewPanel.changeDbIndex(1); // Search by value and return to default database await browserPage.searchByKeyName('Hall School'); await browserPage.OverviewPanel.changeDbIndex(0); // Verify that data changed for indexed db on Search capability page - await verifyKeysDisplayedInTheList([keyNames[0]]); + await verifyKeysDisplayingInTheList([keyNames[0]], true); // Change index to logical db await browserPage.OverviewPanel.changeDbIndex(1); // Verify that search/filter saved after switching index in Search capability @@ -116,14 +116,14 @@ test('Switching between indexed databases', async t => { // Clear filter await t.click(browserPage.clearFilterButton); // Verify that data changed for indexed db on Workbench page (on Search capability page) - await verifyKeysDisplayedInTheList([logicalDbKey]); + await verifyKeysDisplayingInTheList([logicalDbKey], true); await t.click(browserPage.patternModeBtn); // Clear filter await t.click(browserPage.clearFilterButton); // Verify that data changed for indexed db on Workbench page - await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb, logicalDbKey]); + await verifyKeysDisplayingInTheList([keyNameForSearchInLogicalDb, logicalDbKey], true); await browserPage.OverviewPanel.changeDbIndex(0); - await verifyKeysNotDisplayedInTheList([logicalDbKey]); + await verifyKeysDisplayingInTheList([logicalDbKey], false); // Go to Analysis Tools page and create new report await t.click(myRedisDatabasePage.NavigationPanel.analysisPageButton); diff --git a/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts b/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts index ad75b106d5..fc49f6025a 100644 --- a/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts +++ b/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts @@ -113,7 +113,7 @@ test .click(browserPage.treeViewButton) .click(browserPage.clearFilterButton); // Change delimiter - await browserPage.changeDelimiterInTreeView('-'); + await browserPage.TreeView.changeDelimiterInTreeView('-'); // Go to Analysis Tools page await t.click(myRedisDatabasePage.NavigationPanel.analysisPageButton); // Create new report @@ -125,7 +125,7 @@ test // No namespaces message with link await t.click(myRedisDatabasePage.NavigationPanel.browserButton); // Change delimiter to delimiter with no keys - await browserPage.changeDelimiterInTreeView('+'); + await browserPage.TreeView.changeDelimiterInTreeView('+'); // Go to Analysis Tools page and create report await t .click(myRedisDatabasePage.NavigationPanel.analysisPageButton) @@ -135,7 +135,7 @@ test await t.expect(memoryEfficiencyPage.topNamespacesEmptyMessage.textContent).contains(noNamespacesMessage, 'No namespaces message not displayed/correct'); // Verify that user can redirect to Tree view by clicking on button await t.click(memoryEfficiencyPage.treeViewLink); - await t.expect(browserPage.treeViewArea.exists).ok('Tree view not opened'); + await t.expect(browserPage.TreeView.treeViewSettingsBtn.exists).ok('Tree view not opened'); }); test .before(async t => { diff --git a/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts index 1a800a1ba0..e96d37957f 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts @@ -3,8 +3,10 @@ import { commonUrl, ossStandaloneBigConfig } from '../../../../helpers/conf'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { BrowserActions } from '../../../../common-actions/browser-actions'; const browserPage = new BrowserPage(); +const browserActions = new BrowserActions(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -20,19 +22,20 @@ fixture `Delimiter tests` test('Verify that user can see that input is not saved when the Cancel button is clicked', async t => { // Switch to tree view await t.click(browserPage.treeViewButton); + await t.click(browserPage.TreeView.treeViewSettingsBtn); // Check the default delimiter value - await t.expect(browserPage.treeViewDelimiterButton.withExactText(':').exists).ok('Default delimiter not applied'); - // Open delimiter popup - await t.click(browserPage.treeViewDelimiterButton); + await t.expect(browserPage.TreeView.treeViewDelimiterInput.value).eql(':', 'Default delimiter not applied'); // Apply new value to the field - await t.typeText(browserPage.treeViewDelimiterInput, 'test', { replace: true }); + await t.typeText(browserPage.TreeView.treeViewDelimiterInput, 'test', { replace: true }); // Click on Cancel button - await t.click(browserPage.treeViewDelimiterValueCancel); + await t.click(browserPage.TreeView.treeViewDelimiterValueCancel); // Check the previous delimiter value - await t.expect(browserPage.treeViewDelimiterButton.withExactText(':').exists).ok('Previous delimiter not applied'); + await t.click(browserPage.TreeView.treeViewSettingsBtn); + await t.expect(browserPage.TreeView.treeViewDelimiterInput.value).eql(':', 'Previous delimiter not applied'); + await t.click(browserPage.TreeView.treeViewDelimiterValueCancel); // Change delimiter - await browserPage.changeDelimiterInTreeView('-'); + await browserPage.TreeView.changeDelimiterInTreeView('-'); // Verify that when user changes the delimiter and clicks on Save button delimiter is applied - await browserPage.checkTreeViewFoldersStructure([['device_us', 'west'], ['mobile_eu', 'central'], ['mobile_us', 'east'], ['user_us', 'west'], ['device_eu', 'central'], ['user_eu', 'central']], '-', true); + await browserActions.checkTreeViewFoldersStructure([['device_us', 'west'], ['mobile_eu', 'central'], ['mobile_us', 'east'], ['user_us', 'west'], ['device_eu', 'central'], ['user_eu', 'central']], '-', true); }); diff --git a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts index f3db07e435..ab6a43e7f7 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -5,13 +5,11 @@ import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { KeyTypesTexts, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; -import { verifyKeysDisplayedInTheList, verifyKeysNotDisplayedInTheList } from '../../../../helpers/keys'; -import { APIKeyRequests } from '../../../../helpers/api/api-keys'; +import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); -const apiKeyRequests = new APIKeyRequests(); let keyNames: string[]; let keyName1: string; @@ -27,12 +25,11 @@ test await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); }) .after(async() => { - await t.click(browserPage.patternModeBtn); - await browserPage.deleteKeysByNames(keyNames); + await browserPage.Cli.sendCommandInCli('flushdb'); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Tree view preselected folder', async t => { - keyName1 = Common.generateWord(10); // used to create index name - keyName2 = Common.generateWord(10); // used to create index name + keyName1 = Common.generateWord(10); + keyName2 = Common.generateWord(10); keyNameSingle = Common.generateWord(10); keyNames = [`${keyName1}:1`, `${keyName1}:2`, `${keyName2}:1`, `${keyName2}:2`, keyNameSingle]; @@ -41,7 +38,7 @@ test `HSET ${keyNames[0]} field value`, `HSET ${keyNames[1]} field value`, `HSET ${keyNames[2]} field value`, - `HSET ${keyNames[3]} field value`, + `SADD ${keyNames[3]} value`, `SADD ${keyNames[4]} value` ]; @@ -49,17 +46,17 @@ test await browserPage.Cli.sendCommandsInCli(commands); await t.click(browserPage.treeViewButton); // The folder without any patterns selected and the list of keys is displayed when there is a folder without any patterns - await verifyKeysDisplayedInTheList([keyNameSingle]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); - await browserPage.openTreeFolders([await browserPage.getTextFromNthTreeElement(1)]); + await browserPage.TreeView.openTreeFolders([await browserPage.TreeView.getTextFromNthTreeElement(1)]); await browserPage.selectFilterGroupType(KeyTypesTexts.Set); // The folder without any namespaces is selected (if exists) when folder does not exist after search/filter - await verifyKeysDisplayedInTheList([keyNameSingle]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); await browserPage.setAllKeyType(); // The folder without any patterns selected and the list of keys is displayed when there is a folder without any patterns - await verifyKeysDisplayedInTheList([keyNameSingle]); - await verifyKeysNotDisplayedInTheList([`${keyNames[0]}:1`, `${keyNames[2]}:2`]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); + await verifyKeysDisplayingInTheList([`${keyNames[0]}:1`, `${keyNames[2]}:2`], false); // switch between browser view and tree view await t.click(browserPage.browserViewButton) @@ -67,57 +64,58 @@ test await browserPage.deleteKeyByName(keyNames[4]); await t.click(browserPage.clearFilterButton); // get first folder name - const firstTreeItemText = await browserPage.getTextFromNthTreeElement(0); - const firstTreeItemKeys = Selector(`[data-testid="node-item_${firstTreeItemText}:keys:keys:"]`); // keys after node item opened - // The first folder with namespaces is expanded and selected when there is no folder without any patterns - await t.expect(firstTreeItemKeys.visible) - .ok('First folder is not expanded'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + const firstTreeItemText = await browserPage.TreeView.getTextFromNthTreeElement(0); + // All folders with namespaces are collapsed when there is no folder without any patterns + await verifyKeysDisplayingInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`], false); const commands1 = [ - `HSET ${keyNames[4]} field value` + `SADD ${keyNames[4]} value` ]; await browserPage.Cli.sendCommandsInCli(commands1); await t.click(browserPage.refreshKeysButton); - // Refreshed Tree view preselected folder - await t.expect(firstTreeItemKeys.visible) - .ok('Folder is not selected'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + // Folders are collapsed after refresh + await verifyKeysDisplayingInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`], false); + await verifyKeysDisplayingInTheList([keyNameSingle], true); await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); - await t.expect(firstTreeItemKeys.visible).ok('Folder is not selected after searching with HASH'); - // Filtered Tree view preselected folder - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + // Only folders according to key type filter are displayed + await verifyKeysDisplayingInTheList([keyNameSingle], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(firstTreeItemText, true); - await browserPage.searchByKeyName('*'); - // Search capability Filtered Tree view preselected folder - await t.expect(firstTreeItemKeys.visible).ok('Folder is not selected'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + await browserPage.searchByKeyName(`${keyName1}*`); + // Only folders according to filter by key names are displayed + await verifyKeysDisplayingInTheList([keyNameSingle], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); await t.click(browserPage.clearFilterButton); - // Filtered Tree view preselected folder - await t.expect(firstTreeItemKeys.visible).ok('Folder is not selected'); - await verifyKeysDisplayedInTheList([`${firstTreeItemText}:1`, `${firstTreeItemText}:2`]); + // All folders are displayed and collapsed after cleared filter + await verifyKeysDisplayingInTheList([keyNameSingle], true); + await verifyKeysDisplayingInTheList([keyName1], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); await browserPage.selectFilterGroupType(KeyTypesTexts.Stream); - // Filtered Tree view preselected folder - await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Key is not found message not displayed'); + // Verify that No results found message is displayed in case of invalid filtering + await t.expect(browserPage.noResultsFound.textContent).contains('No results found.', 'Key is not found message not displayed'); await browserPage.setAllKeyType(); // clear stream from filter - // Filtered Tree view preselected folder - await t.expect(browserPage.keyListTable.textContent).notContains('No results found.', 'Key is not found message still displayed'); - await t.expect( - firstTreeItemKeys.exists) - .notOk('First folder is expanded'); + // Verify that no results found message not displayed after clearing filter + await t.expect(browserPage.noResultsFound.exists).notOk('Key is not found message still displayed'); + // All folders are displayed and collapsed after cleared filter + await verifyKeysDisplayingInTheList([keyNameSingle], true); + await verifyKeysDisplayingInTheList([keyName1], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); }); - test .before(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); }) .after(async() => { await browserPage.Cli.sendCommandInCli(`FT.DROPINDEX ${index}`); + await browserPage.Cli.sendCommandInCli('flushdb'); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Verify tree view navigation for index based search', async t => { keyName1 = Common.generateWord(10); // used to create index name @@ -139,24 +137,20 @@ test await t.click(browserPage.redisearchModeBtn); // click redisearch button await browserPage.selectIndexByName(index); await t.click(browserPage.treeViewButton); - await t.click(Selector(`[data-testid="${`node-item_${folders[0]}:`}"]`)); // close folder - await browserPage.openTreeFolders(folders); + await browserPage.TreeView.openTreeFolders(folders); await t.click(browserPage.refreshKeysButton); // Refreshed Tree view preselected folder for index based search await t.expect( Selector(`[data-testid="node-item_${folders[0]}:${folders[1]}:keys:keys:"]`).visible) .ok('Folder is not selected'); }); - test .before(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); }) .after(async() => { await t.click(browserPage.patternModeBtn); - for (const element of keyNames.slice(1)) { - await apiKeyRequests.deleteKeyByNameApi(element, ossStandaloneConfig.databaseName); - } + await browserPage.Cli.sendCommandInCli('flushdb'); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Search capability Refreshed Tree view preselected folder', async t => { keyName1 = Common.generateWord(10); @@ -174,24 +168,30 @@ test await browserPage.Cli.sendCommandsInCli(commands); await t.click(browserPage.treeViewButton); // The folder without any patterns selected and the list of keys is displayed when there is a folder without any patterns - await verifyKeysDisplayedInTheList([keyNameSingle]); + await verifyKeysDisplayingInTheList([keyNameSingle], true); - await browserPage.openTreeFolders([keyName1]); // Type: hash - await browserPage.openTreeFolders([keyName2]); // Type: list + await browserPage.TreeView.openTreeFolders([keyName1]); // Type: hash + await browserPage.TreeView.openTreeFolders([keyName2]); // Type: list await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); - // The first folder with namespaces is expanded and selected when folder and folder without any namespaces does not exist after search/filter - await verifyKeysDisplayedInTheList([keyNames[0], keyNames[1]]); + // Only related to key types filter folders are displayed + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, false); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[1]], false); await browserPage.setAllKeyType(); await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames[0]}`]); await t.click(browserPage.refreshKeysButton); // refresh keys - // The previously selected folder is preselected when key does not exist after keys refresh - await verifyKeysDisplayedInTheList([keyNames[1]]); - await verifyKeysNotDisplayedInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]]); + // Only related to filter folders are displayed when key does not exist after keys refresh + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await verifyKeysDisplayingInTheList([keyNames[4]], true); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3]], false); await browserPage.searchByKeyName('*'); await t.click(browserPage.refreshKeysButton); // Search capability Refreshed Tree view preselected folder - await verifyKeysDisplayedInTheList([keyNames[1]]); - await verifyKeysNotDisplayedInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]]); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await verifyKeysDisplayingInTheList([keyNames[4]], true); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3]], false); }); diff --git a/tests/e2e/tests/web/critical-path/tree-view/tree-view.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/tree-view.e2e.ts index 74175a7508..aeab0c37b9 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/tree-view.e2e.ts @@ -3,16 +3,11 @@ import { BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneBigConfig } from '../../../../helpers/conf'; import { rte, KeyTypesTexts } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -import { Common } from '../../../../helpers/common'; import { verifySearchFilterValue } from '../../../../helpers/keys'; -import { APIKeyRequests } from '../../../../helpers/api/api-keys'; const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); -const apiKeyRequests = new APIKeyRequests(); - -const keyNameFilter = `keyName${Common.generateWord(10)}`; fixture `Tree view verifications` .meta({ type: 'critical_path', rte: rte.standalone }) @@ -27,35 +22,17 @@ fixture `Tree view verifications` test('Verify that user can see that "Tree view" mode is enabled state is saved when refreshes the page', async t => { // Verify that when user opens the application he can see that Tree View is disabled by default(Browser is selected by default) await t.expect(browserPage.browserViewButton.getStyleProperty('background-color')).eql('rgb(41, 47, 71)', 'The Browser is not selected by default'); - await t.expect(browserPage.treeViewArea.exists).notOk('The tree view is displayed', { timeout: 10000 }); + await t.expect(browserPage.TreeView.treeViewSettingsBtn.exists).notOk('The tree view is displayed', { timeout: 5000 }); await t.click(browserPage.treeViewButton); await browserPage.reloadPage(); // Verify that "Tree view" mode enabled state is saved - await t.expect(browserPage.treeViewArea.exists).ok('The tree view is not displayed'); + await t.expect(browserPage.TreeView.treeViewSettingsBtn.exists).ok('The tree view is not displayed'); // Verify that user can scan DB by 10K in tree view await browserPage.verifyScannningMore(); }); -test - .after(async() => { - // Clear and delete database - await apiKeyRequests.deleteKeyByNameApi(keyNameFilter, ossStandaloneBigConfig.databaseName); - await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneBigConfig); - })('Verify that when user enables filtering by key name he can see only folder with appropriate keys are displayed and the number of keys and percentage is recalculated', async t => { - await browserPage.addHashKey(keyNameFilter); - await t.click(browserPage.treeViewButton); - const numberOfKeys = await browserPage.treeViewKeysNumber.textContent; - const percentage = await browserPage.treeViewPercentage.textContent; - // Set filter by key name - await browserPage.searchByKeyName(keyNameFilter); - await t.expect(browserPage.treeViewKeysItem.exists).ok('The key not appeared after the filtering', { timeout: 10000 }); - await t.click(browserPage.treeViewKeysItem); - // Verify the results - await t.expect(browserPage.treeViewKeysNumber.textContent).notEql(numberOfKeys, 'The number of keys is not recalculated'); - await t.expect(browserPage.treeViewPercentage.textContent).notEql(percentage, 'The percentage is not recalculated'); - await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNameFilter)).ok('The appropriate keys are not displayed'); - }); +// outdated Verify that when user enables filtering by key name he can see only folder with appropriate keys are displayed and the number of keys and percentage is recalculated test('Verify that when user switched from Tree View to Browser and goes back state of filer by key name/key type is saved', async t => { const keyName = 'user*'; await t.click(browserPage.treeViewButton); diff --git a/tests/e2e/tests/web/regression/browser/filtering.e2e.ts b/tests/e2e/tests/web/regression/browser/filtering.e2e.ts index a637a6e8bc..abc2a24e04 100644 --- a/tests/e2e/tests/web/regression/browser/filtering.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/filtering.e2e.ts @@ -142,10 +142,8 @@ test await browserPage.searchByKeyName(keyName); // Verify that required key is displayed await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).ok('Key not found'); - // Switch to tree view - await t.click(browserPage.treeViewButton); // Check searched key in tree view - await t.click(browserPage.treeViewNotPatternedKeys); + await t.click(browserPage.treeViewButton); await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).ok('Key not found'); }); test diff --git a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts index 59628f00bc..8960514b38 100644 --- a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts @@ -53,24 +53,26 @@ test('Verify that user can see the total number of keys, the number of keys scan await t.expect(browserPage.scanMoreButton.visible).ok('The scan more button is not displayed on the Tree view'); }); test('Verify that when user deletes the key he can see the key is removed from the folder, the number of keys is reduced, the percentage is recalculated', async t => { + const mainFolder = browserPage.TreeView.getFolderSelectorByName('device'); // Open the first key in the tree view and remove await t.click(browserPage.treeViewButton); - // Verify the default separator - await t.expect(browserPage.treeViewSeparator.textContent).eql(':', 'The “:” (colon) not used as a default separator for namespaces'); + await t.click(browserPage.TreeView.treeViewSettingsBtn); + await t.expect(browserPage.TreeView.treeViewDelimiterInput.value).eql(':', 'The “:” (colon) not used as a default separator for namespaces'); // Verify that user can see that “:” (colon) used as a default separator for namespaces and see the number of keys found per each namespace - await t.expect(browserPage.treeViewKeysNumber.visible).ok('The user can not see the number of keys'); + await t.expect(browserPage.TreeView.treeViewKeysNumber.visible).ok('The user can not see the number of keys'); - await t.expect(browserPage.treeViewDeviceFolder.visible).ok('The key folder is not displayed', { timeout: 30000 }); - await t.click(browserPage.treeViewDeviceFolder); - const numberOfKeys = await browserPage.treeViewDeviceKyesCount.textContent; - const keyFolder = await browserPage.treeViewDeviceFolder.nth(2).textContent; - await t.click(browserPage.treeViewDeviceFolder.nth(2)); - await t.click(browserPage.treeViewDeviceFolder.nth(5)); + await t.expect(mainFolder.visible).ok('The key folder is not displayed'); + await t.click(mainFolder); + const numberOfKeys = await browserPage.TreeView.getFolderCountSelectorByName('device').textContent; + const targetFolderName = await mainFolder.nth(1).find(`[data-testid^=folder-]`).textContent; + const targetFolderSelector = browserPage.TreeView.getFolderSelectorByName(`device:${targetFolderName}`); + await t.click(targetFolderSelector); await browserPage.deleteKey(); // Verify the results - await t.expect(browserPage.treeViewDeviceFolder.nth(2).exists).notOk('The previous folder is not closed after removing key folder'); - await t.click(browserPage.treeViewDeviceFolder); - await t.expect(browserPage.treeViewDeviceFolder.nth(2).textContent).notEql(keyFolder, 'The key folder is not removed from the tree view'); - await t.expect(browserPage.treeViewDeviceKyesCount.textContent).notEql(numberOfKeys, 'The number of keys is not recalculated'); + await t.expect(targetFolderSelector.exists).notOk('The previous folder is not closed after removing key folder'); + await t.click(browserPage.TreeView.treeViewDeviceFolder); + await t.expect(mainFolder.nth(1).textContent).notEql(targetFolderName, 'The key folder is not removed from the tree view'); + const actualCount = await browserPage.TreeView.getFolderCountSelectorByName('device').textContent; + await t.expect(+actualCount).lt(+numberOfKeys, 'The number of keys is not recalculated'); }); diff --git a/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts b/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts index d46d3c39d7..af56e3c824 100644 --- a/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts @@ -6,7 +6,7 @@ import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pag import { commonUrl, ossStandaloneConfig, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; -import { deleteAllKeysFromDB, verifyKeysDisplayedInTheList } from '../../../../helpers/keys'; +import { deleteAllKeysFromDB, verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -205,5 +205,5 @@ test await t.click(myRedisDatabasePage.NavigationPanel.browserButton); // Verify that keys of all types can be uploaded await browserPage.searchByKeyName('*key1*'); - await verifyKeysDisplayedInTheList(keyNames); + await verifyKeysDisplayingInTheList(keyNames, true); }); From bd208754c1a211bebcc2d1cb9ae100f02c3de7d0 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 31 Oct 2023 14:50:04 +0100 Subject: [PATCH 07/96] * #RI-5095 - [FE] Scrollbar is displayed for "No keys" panel in tree view when clicked * #RI-5096 - [FE] Delete popup moved at the top left corner when hovering on the key below in list and delete popup opened * #RI-5097 - [FE] Multiple tooltips can be displayed at the same time when hovering over different namespaces after scrolling * #RI-5098 - [FE] Key details are opened for the key in tree view automatically when it wasn't opened in list view * #RI-5100 - [FE] Namespaces folders collapsed after refresh --- .../components/keys-header/KeysHeader.tsx | 1 - .../no-keys-found/styles.module.scss | 1 + .../components/virtual-tree/VirtualTree.tsx | 1 + .../virtual-tree/components/Node/Node.tsx | 70 ++++++++----------- .../components/Node/styles.module.scss | 7 +- .../components/virtual-tree/interfaces.ts | 1 + yarn.lock | 21 +----- 7 files changed, 40 insertions(+), 62 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx index 04a3f9795f..24102f588f 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx @@ -117,7 +117,6 @@ const KeysHeader = (props: Props) => { } const handleRefreshKeys = () => { - dispatch(resetBrowserTree()) dispatch(fetchKeys( { searchMode, diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss b/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss index 73c3a47969..d747185b18 100644 --- a/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/no-keys-found/styles.module.scss @@ -1,5 +1,6 @@ .container { max-width: 400px; + min-height: 440px; margin: auto; text-align: center; diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index 54cd1795b5..552873d9b2 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -187,6 +187,7 @@ const VirtualTree = (props: Props) => { size: node.size, type: node.type, fullName: node.fullName, + shortName: node.nameString?.split(delimiter).pop(), nestingLevel, deleting, path: node.path, diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx index a0ae1b7278..8739c9c7a8 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx @@ -41,6 +41,7 @@ const Node = ({ path, type, ttl, + shortName, size, deleting, nameString, @@ -64,12 +65,6 @@ const Node = ({ } }, []) - useEffect(() => { - if (isSelected && nameBuffer) { - updateStatusSelected?.(nameBuffer) - } - }, [isSelected]) - const handleClick = () => { if (isLeaf && !isSelected) { updateStatusSelected?.(nameBuffer) @@ -98,35 +93,41 @@ const Node = ({ } const Folder = () => ( - <> -
- - - - {nameString} - -
-
-
- {keyApproximate ? `${keyApproximate < 1 ? '<1' : Math.round(keyApproximate)}%` : '' } + + <> +
+ + + + {nameString} +
-
{keyCount ?? ''}
-
- +
+
+ {keyApproximate ? `${keyApproximate < 1 ? '<1' : Math.round(keyApproximate)}%` : '' } +
+
{keyCount ?? ''}
+
+ + ) const Leaf = () => ( <> - + - {isLeaf && Node} - {!isLeaf && ( - - {Node} - - )} + {Node}
) } diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss index 653a14ad0b..dd3be8344d 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/styles.module.scss @@ -1,8 +1,9 @@ .anchorTooltipNode { width: 100%; height: 42px; - display: inline-block; + display: flex !important; position: relative; + align-items: center; } .nodeContainer { @@ -39,13 +40,13 @@ :global(.moveOnHoverKey) { transition: transform ease 0.3s; - &.hide { + &:global(.hide) { transform: translateX(-8px); } } :global(.showOnHoverKey) { display: none !important; - &.show { + &:global(.show) { display: flex !important; } } diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts b/redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts index 8d34865532..119c843d29 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/interfaces.ts @@ -42,6 +42,7 @@ export interface TreeData extends FixedSizeNodeData { keyCount: number keyApproximate: number fullName: string + shortName?: string leafIcon: string type: KeyTypes | ModulesKeyTypes ttl: number diff --git a/yarn.lock b/yarn.lock index 5cf651da51..5f279b5887 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11797,7 +11797,7 @@ postcss-reduce-transforms@^5.1.0: dependencies: postcss-value-parser "^4.2.0" -postcss-selector-parser@^6.0.2: +postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: version "6.0.13" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b" integrity sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ== @@ -11805,14 +11805,6 @@ postcss-selector-parser@^6.0.2: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.0.11" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc" - integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - postcss-svgo@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.1.0.tgz#0a317400ced789f233a28826e77523f15857d80d" @@ -11833,7 +11825,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.2.15: +postcss@^8.2.15, postcss@^8.2.9: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== @@ -11842,15 +11834,6 @@ postcss@^8.2.15: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.2.9: - version "8.4.23" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab" - integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - postinstall-postinstall@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz#4f7f77441ef539d1512c40bd04c71b06a4704ca3" From b0a457a898c666c4147686de0f1416398e61fb92 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 31 Oct 2023 16:57:35 +0100 Subject: [PATCH 08/96] add tests on sorting and fixes --- tests/e2e/pageObjects/browser-page.ts | 2 +- .../components/browser/tree-view.ts | 48 +++++++++++-- .../tree-view/tree-view-improvements.e2e.ts | 6 +- .../tree-view/tree-view-improvements.e2e.ts | 4 +- .../web/regression/browser/add-keys.e2e.ts | 4 +- .../web/regression/tree-view/tree-view.e2e.ts | 71 ++++++++++++++++++- 6 files changed, 121 insertions(+), 14 deletions(-) diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 558bdd890a..5de04d558f 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -207,7 +207,7 @@ export class BrowserPage extends InstancePage { jsonKeyValue = Selector('[data-testid=json-data]'); jsonError = Selector('[data-testid=edit-json-error]'); tooltip = Selector('[role=tooltip]'); - noResultsFound = Selector('[data-testid=no-result-found-only]'); + noResultsFound = Selector('[data-test-subj=no-result-found]'); searchAdvices = Selector('[data-test-subj=search-advices]'); keysNumberOfResults = Selector('[data-testid=keys-number-of-results]'); keysTotalNumber = Selector('[data-testid=keys-total]'); diff --git a/tests/e2e/pageObjects/components/browser/tree-view.ts b/tests/e2e/pageObjects/components/browser/tree-view.ts index 0472b24f6b..d3fd5c75ae 100644 --- a/tests/e2e/pageObjects/components/browser/tree-view.ts +++ b/tests/e2e/pageObjects/components/browser/tree-view.ts @@ -1,4 +1,5 @@ import { Selector, t } from 'testcafe'; +import { Common } from '../../../helpers/common'; export class TreeView { //------------------------------------------------------------------------------------------- @@ -11,13 +12,16 @@ export class TreeView { treeViewSettingsBtn = Selector('[data-testid=tree-view-settings-btn]'); treeViewDelimiterValueSave = Selector('[data-testid=tree-view-apply-btn]'); treeViewDelimiterValueCancel = Selector('[data-testid=tree-view-cancel-btn]'); + sortingBtn = Selector('[data-testid=tree-view-sorting-select]'); + sortingASCoption = Selector('[id=ASC]'); + sortingDESCoption = Selector('[id=DESC]'); + sortingProgressBar = Selector('[data-testid=progress-key-tree]'); // TEXT ELEMENTS treeViewKeysNumber = Selector('[data-testid^=count_]'); treeViewDeviceFolder = Selector('[data-testid^=node-item_device] div'); //INPUTS treeViewDelimiterInput = Selector('[data-testid=tree-view-delimiter-input]'); - /** * Get folder selector by folder name * @param folderName The name of the folder @@ -31,8 +35,8 @@ export class TreeView { * @param folderName The name of the folder */ getFolderCountSelectorByName(folderName: string): Selector { - return Selector(`[data-testid^="count_${folderName}"]`); - } + return Selector(`[data-testid^="count_${folderName}"]`); + } /** * Verifying if the Keys are in the List of keys @@ -40,7 +44,7 @@ export class TreeView { * @param isDisplayed True if keys should be displayed */ async verifyFolderDisplayingInTheList(folderName: string, isDisplayed: boolean): Promise { - isDisplayed + isDisplayed ? await t.expect(this.getFolderSelectorByName(folderName).exists).ok(`The folder ${folderName} not found`) : await t.expect(this.getFolderSelectorByName(folderName).exists).notOk(`The folder ${folderName} found`); } @@ -58,6 +62,23 @@ export class TreeView { await t.click(this.treeViewDelimiterValueSave); } + /** + * Change ordering value + * @param order ASC/DESC ordering for tree view + */ + async changeOrderingInTreeView(order: string): Promise { + // Open settings popup + await t.click(this.treeViewSettingsBtn); + await t.click(this.sortingBtn); + order === 'ASC' + ? await t.click(this.sortingASCoption) + : await t.click(this.sortingDESCoption) + + // Click on save button + await t.click(this.treeViewDelimiterValueSave); + await Common.waitForElementNotVisible(this.sortingProgressBar); + } + /** * Get text from tree element by number * @param number The number of tree folder @@ -80,4 +101,23 @@ export class TreeView { } } } + + /** + * Get all keys from tree view list with order + */ + async getAllItemsArray(): Promise { + const textArray: string[] = []; + const treeViewItemElements = Selector('[role="treeitem"]'); + const itemCount = await treeViewItemElements.count; + + for (let i = 0; i < itemCount; i++) { + const treeItem = treeViewItemElements.nth(i); + const keyItem = treeItem.find('[data-testid^="key-"]'); + if (await keyItem.exists) { + textArray.push(await keyItem.textContent); + } + } + + return textArray; + } } diff --git a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts index 4a5126f300..7b2a83ab00 100644 --- a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -117,6 +117,7 @@ test }) .after(async() => { await browserPage.Cli.sendCommandInCli(`FT.DROPINDEX ${index}`); + await browserPage.Cli.sendCommandInCli('flushdb'); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Verify tree view navigation for index based search', async t => { keyName1 = Common.generateWord(10); // used to create index name @@ -138,13 +139,10 @@ test await t.click(browserPage.redisearchModeBtn); // click redisearch button await browserPage.selectIndexByName(index); await t.click(browserPage.treeViewButton); - await t.click(Selector(`[data-testid="${`node-item_${folders[0]}:`}"]`)); // close folder await browserPage.TreeView.openTreeFolders(folders); await t.click(browserPage.refreshKeysButton); // Refreshed Tree view preselected folder for index based search - await t.expect( - Selector(`[data-testid="node-item_${folders[0]}:${folders[1]}:keys:keys:"]`).visible) - .ok('Folder is not selected'); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); }); test diff --git a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts index ab6a43e7f7..574533dfb6 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -140,9 +140,7 @@ test await browserPage.TreeView.openTreeFolders(folders); await t.click(browserPage.refreshKeysButton); // Refreshed Tree view preselected folder for index based search - await t.expect( - Selector(`[data-testid="node-item_${folders[0]}:${folders[1]}:keys:keys:"]`).visible) - .ok('Folder is not selected'); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); }); test .before(async() => { diff --git a/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts b/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts index 82a11fb17f..5f9dbcd53e 100644 --- a/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts @@ -5,6 +5,7 @@ import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../.. import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; import { BrowserActions } from '../../../../common-actions/browser-actions'; +import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const browserPage = new BrowserPage(); const browserActions = new BrowserActions(); @@ -96,7 +97,8 @@ test await t.click(browserPage.treeViewButton); await browserPage.addHashKey(keyName3); // Verify that user can see Tree view recalculated when new key is added in Tree view - await browserActions.verifyKeyDisplayedTopAndOpened(keyName3); + await browserActions.verifyKeyIsNotDisplayedTop(keyName3); + await verifyKeysDisplayingInTheList([keyName3], true); await t.click(browserPage.redisearchModeBtn); await browserPage.selectIndexByName(indexName); diff --git a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts index 8960514b38..75db76c555 100644 --- a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts @@ -3,15 +3,21 @@ import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneBigConfig, + ossStandaloneConfigEmpty, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { KeyTypesTexts, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { APIKeyRequests } from '../../../../helpers/api/api-keys'; +import { Common } from '../../../../helpers/common'; const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const apiKeyRequests = new APIKeyRequests(); + +let keyNames: string[] = []; fixture `Tree view verifications` .meta({ type: 'regression', rte: rte.standalone }) @@ -65,7 +71,7 @@ test('Verify that when user deletes the key he can see the key is removed from t await t.expect(mainFolder.visible).ok('The key folder is not displayed'); await t.click(mainFolder); const numberOfKeys = await browserPage.TreeView.getFolderCountSelectorByName('device').textContent; - const targetFolderName = await mainFolder.nth(1).find(`[data-testid^=folder-]`).textContent; + const targetFolderName = await mainFolder.nth(1).find('[data-testid^=folder-]').textContent; const targetFolderSelector = browserPage.TreeView.getFolderSelectorByName(`device:${targetFolderName}`); await t.click(targetFolderSelector); await browserPage.deleteKey(); @@ -76,3 +82,66 @@ test('Verify that when user deletes the key he can see the key is removed from t const actualCount = await browserPage.TreeView.getFolderCountSelectorByName('device').textContent; await t.expect(+actualCount).lt(+numberOfKeys, 'The number of keys is not recalculated'); }); +test + .before(async() => { + await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfigEmpty); + }) + .after(async() => { + // Clear and delete database + for(const name of keyNames) { + await apiKeyRequests.deleteKeyByNameApi(name, ossStandaloneConfigEmpty.databaseName); + } + await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfigEmpty); + })('Verify that if there are keys without namespaces, they are displayed in the root directory after all folders by default in the Tree view', async t => { + keyNames = [ + `atest:a-${Common.generateWord(10)}`, + `atest:z-${Common.generateWord(10)}`, + `ztest:a-${Common.generateWord(10)}`, + `ztest:z-${Common.generateWord(10)}`, + `atest-${Common.generateWord(10)}`, + `ztest-${Common.generateWord(10)}` + ]; + const commands = [ + 'flushdb', + `HSET ${keyNames[0]} field value`, + `HSET ${keyNames[1]} field value`, + `HSET ${keyNames[2]} field value`, + `SADD ${keyNames[3]} value`, + `SADD ${keyNames[4]} value`, + `HSET ${keyNames[5]} field value` + ]; + const expectedSortedByASC = [ + keyNames[0].split(':')[1], + keyNames[1].split(':')[1], + keyNames[2].split(':')[1], + keyNames[3].split(':')[1], + keyNames[4], + keyNames[5] + ]; + const expectedSortedByDESC = [ + keyNames[3].split(':')[1], + keyNames[2].split(':')[1], + keyNames[1].split(':')[1], + keyNames[0].split(':')[1], + keyNames[5], + keyNames[4] + ]; + + // Create 5 keys + await browserPage.Cli.sendCommandsInCli(commands); + await t.click(browserPage.treeViewButton); + + // Verify that if there are keys without namespaces, they are displayed in the root directory after all folders by default in the Tree view + await browserPage.TreeView.openTreeFolders([`${keyNames[0]}`.split(':')[0]]); + await browserPage.TreeView.openTreeFolders([`${keyNames[2]}`.split(':')[0]]); + let actualItemsArray = await browserPage.TreeView.getAllItemsArray(); + // Verify that user can see all folders and keys sorted by name ASC by default + await t.expect(actualItemsArray).eql(expectedSortedByASC); + + // Verify that user can change the sorting ASC-DESC + await browserPage.TreeView.changeOrderingInTreeView('DESC'); + await browserPage.TreeView.openTreeFolders([`${keyNames[2]}`.split(':')[0]]); + await browserPage.TreeView.openTreeFolders([`${keyNames[0]}`.split(':')[0]]); + actualItemsArray = await browserPage.TreeView.getAllItemsArray(); + await t.expect(actualItemsArray).eql(expectedSortedByDESC); + }); From cacbec78ed519838013252e834306da9a6b8a096 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 31 Oct 2023 18:07:59 +0100 Subject: [PATCH 09/96] fixes after bugfix --- tests/e2e/common-actions/browser-actions.ts | 2 +- tests/e2e/desktop.runner.ts | 1 + tests/e2e/desktop.runner.win.ts | 1 + tests/e2e/pageObjects/browser-page.ts | 1 + .../critical-path/tree-view/tree-view-improvements.e2e.ts | 4 ++-- .../web/critical-path/tree-view/tree-view-improvements.e2e.ts | 4 ++-- tests/e2e/tests/web/regression/browser/add-keys.e2e.ts | 2 +- 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/e2e/common-actions/browser-actions.ts b/tests/e2e/common-actions/browser-actions.ts index a0afa741ae..847537a906 100644 --- a/tests/e2e/common-actions/browser-actions.ts +++ b/tests/e2e/common-actions/browser-actions.ts @@ -94,7 +94,7 @@ export class BrowserActions { // Verify that the last folder level contains required keys const foundKeyName = `${folders[i].join(delimiter)}`; await t - .expect(Selector(`[data-testid*="key-${foundKeyName}"]`).exists).ok('Specific key not found') + .expect(Selector(`[data-testid*="node-item_${foundKeyName}"]`).find('[data-testid^="key-"]').exists).ok('Specific key not found') .click(array[0]); } } diff --git a/tests/e2e/desktop.runner.ts b/tests/e2e/desktop.runner.ts index 11444f3694..a0f6be3b94 100644 --- a/tests/e2e/desktop.runner.ts +++ b/tests/e2e/desktop.runner.ts @@ -38,6 +38,7 @@ import testcafe from 'testcafe'; selectorTimeout: 5000, assertionTimeout: 5000, speed: 1, + quarantineMode: { successThreshold: 1, attemptLimit: 3 }, }); }) .then((failedCount) => { diff --git a/tests/e2e/desktop.runner.win.ts b/tests/e2e/desktop.runner.win.ts index 20ec80beb1..5755eeb38d 100644 --- a/tests/e2e/desktop.runner.win.ts +++ b/tests/e2e/desktop.runner.win.ts @@ -38,6 +38,7 @@ import testcafe from 'testcafe'; selectorTimeout: 5000, assertionTimeout: 5000, speed: 1, + quarantineMode: { successThreshold: 1, attemptLimit: 3 }, }); }) .then((failedCount) => { diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 5de04d558f..98787eb7bf 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -208,6 +208,7 @@ export class BrowserPage extends InstancePage { jsonError = Selector('[data-testid=edit-json-error]'); tooltip = Selector('[role=tooltip]'); noResultsFound = Selector('[data-test-subj=no-result-found]'); + noResultsFoundOnly = Selector('[data-testid=no-result-found-only]'); searchAdvices = Selector('[data-test-subj=search-advices]'); keysNumberOfResults = Selector('[data-testid=keys-number-of-results]'); keysTotalNumber = Selector('[data-testid=keys-total]'); diff --git a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts index 7b2a83ab00..fd83380795 100644 --- a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -100,11 +100,11 @@ test await browserPage.selectFilterGroupType(KeyTypesTexts.Stream); // Verify that No results found message is displayed in case of invalid filtering - await t.expect(browserPage.noResultsFound.textContent).contains('No results found.', 'Key is not found message not displayed'); + await t.expect(browserPage.noResultsFoundOnly.textContent).contains('No results found.', 'Key is not found message not displayed'); await browserPage.setAllKeyType(); // clear stream from filter // Verify that no results found message not displayed after clearing filter - await t.expect(browserPage.noResultsFound.exists).notOk('Key is not found message still displayed'); + await t.expect(browserPage.noResultsFoundOnly.exists).notOk('Key is not found message still displayed'); // All folders are displayed and collapsed after cleared filter await verifyKeysDisplayingInTheList([keyNameSingle], true); await verifyKeysDisplayingInTheList([keyName1], false); diff --git a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts index 574533dfb6..5fbceb27a4 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -98,11 +98,11 @@ test await browserPage.selectFilterGroupType(KeyTypesTexts.Stream); // Verify that No results found message is displayed in case of invalid filtering - await t.expect(browserPage.noResultsFound.textContent).contains('No results found.', 'Key is not found message not displayed'); + await t.expect(browserPage.noResultsFoundOnly.textContent).contains('No results found.', 'Key is not found message not displayed'); await browserPage.setAllKeyType(); // clear stream from filter // Verify that no results found message not displayed after clearing filter - await t.expect(browserPage.noResultsFound.exists).notOk('Key is not found message still displayed'); + await t.expect(browserPage.noResultsFoundOnly.exists).notOk('Key is not found message still displayed'); // All folders are displayed and collapsed after cleared filter await verifyKeysDisplayingInTheList([keyNameSingle], true); await verifyKeysDisplayingInTheList([keyName1], false); diff --git a/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts b/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts index 5f9dbcd53e..4da14a05ab 100644 --- a/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts @@ -98,7 +98,7 @@ test await browserPage.addHashKey(keyName3); // Verify that user can see Tree view recalculated when new key is added in Tree view await browserActions.verifyKeyIsNotDisplayedTop(keyName3); - await verifyKeysDisplayingInTheList([keyName3], true); + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName3).exists).ok(`Key ${keyName3} details not opened`); await t.click(browserPage.redisearchModeBtn); await browserPage.selectIndexByName(indexName); From 6d4b10072b10cce3c48d6dab4ea94ab9c3a502c7 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 31 Oct 2023 18:59:34 +0100 Subject: [PATCH 10/96] fix --- .../tree-view/tree-view-improvements.e2e.ts | 28 ++++++++++--------- .../tree-view/tree-view-improvements.e2e.ts | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts index fd83380795..1daae0eb76 100644 --- a/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -1,4 +1,4 @@ -import { Selector, t } from 'testcafe'; +import { t } from 'testcafe'; import { DatabaseHelper } from '../../../../helpers/database'; import { BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; @@ -6,12 +6,10 @@ import { KeyTypesTexts, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; -import { APIKeyRequests } from '../../../../helpers/api/api-keys'; const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); -const apiKeyRequests = new APIKeyRequests(); let keyNames: string[]; let keyName1: string; @@ -151,9 +149,7 @@ test }) .after(async() => { await t.click(browserPage.patternModeBtn); - for (const element of keyNames.slice(1)) { - await apiKeyRequests.deleteKeyByNameApi(element, ossStandaloneConfig.databaseName); - } + await browserPage.Cli.sendCommandInCli('flushdb'); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Search capability Refreshed Tree view preselected folder', async t => { keyName1 = Common.generateWord(10); @@ -176,19 +172,25 @@ test await browserPage.TreeView.openTreeFolders([keyName1]); // Type: hash await browserPage.TreeView.openTreeFolders([keyName2]); // Type: list await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); - // The first folder with namespaces is expanded and selected when folder and folder without any namespaces does not exist after search/filter - await verifyKeysDisplayingInTheList([keyNames[0], keyNames[1]], true); + // Only related to key types filter folders are displayed + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, false); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[1]], false); await browserPage.setAllKeyType(); await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames[0]}`]); await t.click(browserPage.refreshKeysButton); // refresh keys - // The previously selected folder is preselected when key does not exist after keys refresh - await verifyKeysDisplayingInTheList([keyNames[1]], true); - await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]], false); + // Only related to filter folders are displayed when key does not exist after keys refresh + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await verifyKeysDisplayingInTheList([keyNames[4]], true); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3]], false); await browserPage.searchByKeyName('*'); await t.click(browserPage.refreshKeysButton); // Search capability Refreshed Tree view preselected folder - await verifyKeysDisplayingInTheList([keyNames[1]], true); - await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3], keyNames[4]], false); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName1, true); + await browserPage.TreeView.verifyFolderDisplayingInTheList(keyName2, true); + await verifyKeysDisplayingInTheList([keyNames[4]], true); + await verifyKeysDisplayingInTheList([keyNames[0], keyNames[2], keyNames[3]], false); }); diff --git a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts index 5fbceb27a4..30bad9bec1 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/tree-view-improvements.e2e.ts @@ -1,4 +1,4 @@ -import { Selector, t } from 'testcafe'; +import { t } from 'testcafe'; import { DatabaseHelper } from '../../../../helpers/database'; import { BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; From 9a90d93bde155d036f2fcbe583669713555c8c47 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 31 Oct 2023 21:23:03 +0200 Subject: [PATCH 11/96] escape "-" symbol to avoid regex range --- redisinsight/ui/src/utils/validations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/utils/validations.ts b/redisinsight/ui/src/utils/validations.ts index bc162234cb..70ae4226c5 100644 --- a/redisinsight/ui/src/utils/validations.ts +++ b/redisinsight/ui/src/utils/validations.ts @@ -111,7 +111,7 @@ export const errorValidateNegativeInteger = (value: string) => { } export const validateCertName = (initValue: string) => - initValue.replace(/[^ a-zA-Z0-9!@#$%^&*-_()[\]]+/gi, '').toString() + initValue.replace(/[^ a-zA-Z0-9!@#$%^&*\-_()[\]]+/gi, '').toString() export const isRequiredStringsValid = (...params: string[]) => params.every((p = '') => p.length > 0) From 84a379d0079e804c576012f64f479206af5459b6 Mon Sep 17 00:00:00 2001 From: Zalenski Egor Date: Wed, 1 Nov 2023 01:35:15 +0300 Subject: [PATCH 12/96] Update Node.spec.tsx --- .../components/virtual-tree/components/Node/Node.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx index 588cd1df4b..a5df9953fe 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx @@ -107,7 +107,7 @@ describe('Node', () => { expect(mockSetOpen).not.toBeCalled() }) - it.only('name, ttl and size should be rendered', () => { + it('name, ttl and size should be rendered', () => { const { getByTestId } = render( Date: Wed, 1 Nov 2023 09:40:02 +0400 Subject: [PATCH 13/96] #RI-5009 - refactor database form --- redisinsight/ui/src/pages/home/HomePage.tsx | 93 +- .../InstanceForm/InstanceForm.spec.tsx | 1258 ----------------- .../InstanceForm/InstanceForm.tsx | 783 ---------- .../AddInstanceForm/InstanceForm/index.ts | 5 - .../InstanceFormWrapper.spec.tsx | 210 --- .../home/components/AddInstanceForm/index.ts | 3 - .../CloudConnectionForm/index.ts | 3 + .../CloudConnectionFormWrapper.tsx | 2 +- .../home/components/CloudConnection/index.ts | 3 + .../ClusterConnectionForm/index.ts | 3 + .../ClusterConnectionFormWrapper.tsx | 49 +- .../components/ClusterConnection/index.ts | 3 + .../DatabaseAlias/DatabaseAlias.tsx | 2 +- .../DatabasesList/index.ts | 3 + .../DatabasesListWrapper.spec.tsx | 2 +- .../DatabasesListWrapper.tsx | 2 +- .../DatabasesListComponent/index.ts | 3 + .../form-components => Form}/DatabaseForm.tsx | 12 +- .../form-components => Form}/DbCompressor.tsx | 4 +- .../form-components => Form}/DbIndex.tsx | 2 +- .../form-components => Form}/DbInfo.tsx | 0 .../form-components => Form}/Messages.tsx | 0 .../form-components => Form}/SSHDetails.tsx | 4 +- .../form-components => Form}/TlsDetails.tsx | 4 +- .../form-components => Form}/index.ts | 0 .../sentinel/DbInfoSentinel.tsx | 0 .../sentinel/PrimaryGroupSentinel.tsx | 2 +- .../sentinel/SentinelHostPort.tsx | 0 .../sentinel/SentinelMasterDatabase.tsx | 2 +- .../sentinel/index.ts | 0 .../ManualConnectionForm.tsx | 662 +++++++++ .../ManualConnectionForm/index.ts | 3 + .../ManualConnectionWrapper.tsx} | 224 +-- .../home/components/ManualConnection/index.ts | 3 + .../InstanceConnections.spec.tsx | 0 .../InstanceConnections.tsx | 3 +- .../RightPanel.spec.tsx} | 6 +- .../RightPanel.tsx} | 66 +- .../pages/home/components/RightPanel/index.ts | 3 + .../styles.module.scss | 0 .../SentinelConnectionForm.tsx | 200 +++ .../SentinelConnectionForm/index.ts | 3 + .../SentinelConnectionWrapper.tsx | 163 +++ .../components/SentinelConnection/index.ts | 3 + .../WelcomeComponent.spec.tsx | 2 +- .../WelcomeComponent/WelcomeComponent.tsx | 8 +- .../home/components/WelcomeComponent/index.ts | 3 + .../InstanceForm => }/styles.module.scss | 0 .../ui/src/pages/home/constants/database.ts | 4 + .../constants.ts => constants/form.ts} | 7 + .../ui/src/pages/home/constants/index.ts | 3 + .../interfaces.ts => interfaces/form.ts} | 7 +- .../ui/src/pages/home/interfaces/index.ts | 1 + redisinsight/ui/src/pages/home/utils/form.tsx | 229 +++ redisinsight/ui/src/pages/home/utils/index.ts | 1 + .../edit-connection/EditConnection.tsx | 6 +- .../UploadTutorialForm/UploadTutorialForm.tsx | 2 +- .../ui/src/slices/instances/instances.ts | 2 +- .../ui/src/slices/interfaces/instances.ts | 2 +- 59 files changed, 1464 insertions(+), 2609 deletions(-) delete mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/index.ts delete mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/index.ts rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/DatabaseForm.tsx (97%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/DbCompressor.tsx (96%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/DbIndex.tsx (97%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/DbInfo.tsx (100%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/Messages.tsx (100%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/SSHDetails.tsx (98%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/TlsDetails.tsx (99%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/sentinel/DbInfoSentinel.tsx (100%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/sentinel/PrimaryGroupSentinel.tsx (96%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/sentinel/SentinelHostPort.tsx (100%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/sentinel/SentinelMasterDatabase.tsx (97%) rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm/form-components => Form}/sentinel/index.ts (100%) create mode 100644 redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx create mode 100644 redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/index.ts rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceFormWrapper.tsx => ManualConnection/ManualConnectionWrapper.tsx} (67%) create mode 100644 redisinsight/ui/src/pages/home/components/ManualConnection/index.ts rename redisinsight/ui/src/pages/home/components/{AddDatabases => RightPanel}/InstanceConnections/InstanceConnections.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{AddDatabases => RightPanel}/InstanceConnections/InstanceConnections.tsx (98%) rename redisinsight/ui/src/pages/home/components/{AddDatabases/AddDatabasesContainer.spec.tsx => RightPanel/RightPanel.spec.tsx} (70%) rename redisinsight/ui/src/pages/home/components/{AddDatabases/AddDatabasesContainer.tsx => RightPanel/RightPanel.tsx} (82%) create mode 100644 redisinsight/ui/src/pages/home/components/RightPanel/index.ts rename redisinsight/ui/src/pages/home/components/{AddDatabases => RightPanel}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx create mode 100644 redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx create mode 100644 redisinsight/ui/src/pages/home/components/SentinelConnection/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/WelcomeComponent/index.ts rename redisinsight/ui/src/pages/home/components/{AddInstanceForm/InstanceForm => }/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/home/constants/database.ts rename redisinsight/ui/src/pages/home/{components/AddInstanceForm/InstanceForm/constants.ts => constants/form.ts} (83%) create mode 100644 redisinsight/ui/src/pages/home/constants/index.ts rename redisinsight/ui/src/pages/home/{components/AddInstanceForm/InstanceForm/interfaces.ts => interfaces/form.ts} (89%) create mode 100644 redisinsight/ui/src/pages/home/interfaces/index.ts create mode 100644 redisinsight/ui/src/pages/home/utils/form.tsx create mode 100644 redisinsight/ui/src/pages/home/utils/index.ts diff --git a/redisinsight/ui/src/pages/home/HomePage.tsx b/redisinsight/ui/src/pages/home/HomePage.tsx index a4c8fc529b..08af6d8c41 100644 --- a/redisinsight/ui/src/pages/home/HomePage.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { clusterSelector, resetDataRedisCluster, resetInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' -import { setTitle } from 'uiSrc/utils' +import { Nullable, setTitle } from 'uiSrc/utils' import { PageHeader } from 'uiSrc/components' import { BrowserStorageItem } from 'uiSrc/constants' import { resetKeys } from 'uiSrc/slices/browser/keys' @@ -25,19 +25,23 @@ import { fetchContentAction as fetchCreateRedisButtonsAction } from 'uiSrc/slice import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' -import AddDatabaseContainer, { AddDbType } from './components/AddDatabases/AddDatabasesContainer' -import DatabasesList from './components/DatabasesListComponent/DatabasesListWrapper' -import WelcomeComponent from './components/WelcomeComponent/WelcomeComponent' +import { AddDbType } from 'uiSrc/pages/home/constants' +import RightPanel from 'uiSrc/pages/home/components/RightPanel' +import DatabasesList from './components/DatabasesListComponent' +import WelcomeComponent from './components/WelcomeComponent' import HomeHeader from './components/HomeHeader' import './styles.scss' import styles from './styles.module.scss' +enum RightPanelName { + AddDatabase = 'add', + EditDatabase = 'edit' +} + const HomePage = () => { const [width, setWidth] = useState(0) - const [addDialogIsOpen, setAddDialogIsOpen] = useState(false) - const [editDialogIsOpen, setEditDialogIsOpen] = useState(false) - const [dialogIsOpen, setDialogIsOpen] = useState(false) + const [openRightPanel, setOpenRightPanel] = useState>(null) const [welcomeIsShow, setWelcomeIsShow] = useState( !localStorageService.get(BrowserStorageItem.instancesCount) ) @@ -86,8 +90,7 @@ const HomePage = () => { useEffect(() => { if (isChangedInstance) { - setAddDialogIsOpen(!isChangedInstance) - setEditDialogIsOpen(!isChangedInstance) + setOpenRightPanel(null) dispatch(setEditedInstance(null)) // send page view after adding database from welcome page sendPageViewTelemetry({ @@ -107,29 +110,25 @@ const HomePage = () => { useEffect(() => { if (clusterCredentials || cloudCredentials || sentinelInstance) { - setAddDialogIsOpen(true) + setOpenRightPanel(RightPanelName.AddDatabase) } }, [clusterCredentials, cloudCredentials, sentinelInstance]) useEffect(() => { if (action === UrlHandlingActions.Connect) { - setAddDialogIsOpen(true) + setOpenRightPanel(RightPanelName.AddDatabase) } }, [action, dbConnection]) useEffect(() => { - const isDialogOpen = !!instances.length && (addDialogIsOpen || editDialogIsOpen) - const instancesCashCount = JSON.parse( localStorageService.get(BrowserStorageItem.instancesCount) ?? '0' ) - const isShowWelcome = !instances.length && !addDialogIsOpen && !editDialogIsOpen && !instancesCashCount - - setDialogIsOpen(isDialogOpen) + const isShowWelcome = !instances.length && !openRightPanel && !instancesCashCount setWelcomeIsShow(isShowWelcome) - }, [addDialogIsOpen, editDialogIsOpen, instances, loading]) + }, [openRightPanel, instances, loading]) useEffect(() => { if (editedInstance) { @@ -152,7 +151,7 @@ const HomePage = () => { const closeEditDialog = () => { dispatch(setEditedInstance(null)) - setEditDialogIsOpen(false) + setOpenRightPanel(null) sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_DATABASE_EDIT_CANCELLED_CLICKED, @@ -166,9 +165,8 @@ const HomePage = () => { dispatch(resetDataRedisCluster()) dispatch(resetDataSentinel()) - setAddDialogIsOpen(false) + setOpenRightPanel(null) dispatch(setEditedInstance(null)) - setEditDialogIsOpen(false) if (action === UrlHandlingActions.Connect) { dispatch(setUrlHandlingInitialState()) @@ -181,22 +179,23 @@ const HomePage = () => { const handleAddInstance = (addDbType = AddDbType.manual) => { initialDbTypeRef.current = addDbType - setAddDialogIsOpen(true) + setOpenRightPanel(RightPanelName.AddDatabase) dispatch(setEditedInstance(null)) - setEditDialogIsOpen(false) } const handleEditInstance = (editedInstance: Instance) => { if (editedInstance) { dispatch(fetchEditedInstanceAction(editedInstance)) - setEditDialogIsOpen(true) - setAddDialogIsOpen(false) + setOpenRightPanel(RightPanelName.EditDatabase) } } const handleDeleteInstances = (instances: Instance[]) => { - if (instances.find((instance) => instance.id === editedInstance?.id)) { + if ( + instances.find((instance) => instance.id === editedInstance?.id) + && openRightPanel === RightPanelName.EditDatabase + ) { dispatch(setEditedInstance(null)) - setEditDialogIsOpen(false) + setOpenRightPanel(null) } instances.forEach((instance) => { @@ -227,7 +226,7 @@ const HomePage = () => { onAddInstance={handleAddInstance} direction="row" /> - {dialogIsOpen ? ( + {openRightPanel && instances.length ? (
{(EuiResizablePanel, EuiResizableButton) => ( @@ -242,7 +241,7 @@ const HomePage = () => {
{ scrollable={false} initialSize={38} className={cx({ - [styles.contentActive]: editDialogIsOpen, + [styles.contentActive]: openRightPanel === RightPanelName.EditDatabase, })} id="form" paddingSize="none" style={{ minWidth: '494px' }} > - {editDialogIsOpen && ( - - )} - - {addDialogIsOpen && ( - )} @@ -297,14 +294,14 @@ const HomePage = () => { ) : ( <> - {addDialogIsOpen && ( - () -const mockedDbConnectionInfo = mock() - -const formFields = { - ...instance(mockedDbConnectionInfo), - host: 'localhost', - port: '6379', - name: 'lala', - caCertificates: [], - certificates: [], -} - -jest.mock('uiSrc/slices/instances/instances', () => ({ - checkConnectToInstanceAction: () => jest.fn, - resetInstanceUpdateAction: () => jest.fn, - changeInstanceAliasAction: () => jest.fn, - setConnectedInstanceId: jest.fn, -})) - -jest.mock('uiSrc/slices/app/url-handling', () => ({ - ...jest.requireActual('uiSrc/slices/app/url-handling'), - appRedirectionSelector: jest.fn().mockReturnValue(() => ({ action: null })), -})) - -describe('InstanceForm', () => { - it('should render', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should render with ConnectionType.Sentinel', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should render with ConnectionType.Cluster', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should render tooltip with nodes', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should render DatabaseForm', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should change sentinelMasterUsername input properly', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - - render( -
- -
- ) - - await act(() => { - fireEvent.change(screen.getByTestId('sentinel-mater-username'), { - target: { value: 'user' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - sentinelMasterUsername: 'user', - }) - ) - - await act(() => { - fireEvent.click(submitBtn) - }) - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - sentinelMasterUsername: 'user', - }) - ) - }) - - it('should change port input properly', async () => { - const handleSubmit = jest.fn() - render( -
- -
- ) - - await act(() => { - fireEvent.change(screen.getByTestId('port'), { - target: { value: '123' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - await act(() => { - fireEvent.click(submitBtn) - }) - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - port: '123', - }) - ) - }) - - it('should change tls checkbox', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('tls')) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - tls: ['on'], - }) - ) - - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - tls: ['on'], - }) - ) - }) - - it('should change Database Index checkbox', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('showDb')) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - showDb: true, - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - showDb: true, - }) - ) - }) - - it('should change db checkbox and value', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('showDb')) - }) - - await act(() => { - fireEvent.change(screen.getByTestId('db'), { - target: { value: '12' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - showDb: true, - db: '12' - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - showDb: true, - db: '12' - }) - ) - }) - - it('should change "Use SNI" with prepopulated with host', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('sni')) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - sni: true, - servername: formFields.host - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - sni: true, - servername: formFields.host - }) - ) - }) - - it('should change "Use SNI"', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('sni')) - }) - - await act(() => { - fireEvent.change(screen.getByTestId('sni-servername'), { - target: { value: '12' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - sni: true, - servername: '12' - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - sni: true, - servername: '12' - }) - ) - }) - - it('should change "Verify TLS Certificate"', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('verify-tls-cert')) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - verifyServerTlsCert: ['on'], - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - verifyServerTlsCert: ['on'], - }) - ) - }) - - it('should select value from "CA Certificate"', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - const { queryByText } = render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('select-ca-cert')) - }) - await act(() => { - fireEvent.click(queryByText('Add new CA certificate') || document) - }) - - expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId(NEW_CA_CERT), { - target: { value: '123' }, - }) - }) - - expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId(QA_CA_CERT), { - target: { value: '321' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - selectedCaCertName: ADD_NEW_CA_CERT, - newCaCertName: '321', - newCaCert: '123', - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - selectedCaCertName: ADD_NEW_CA_CERT, - newCaCertName: '321', - newCaCert: '123', - }) - ) - }) - - it('should render fields for add new CA and change them properly', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - - expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId(QA_CA_CERT), { - target: { value: '321' }, - }) - }) - - expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId(NEW_CA_CERT), { - target: { value: '123' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - newCaCert: '123', - newCaCertName: '321', - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - newCaCert: '123', - newCaCertName: '321', - }) - ) - }) - - it('should change "Requires TLS Client Authentication"', async () => { - const handleSubmit = jest.fn() - const handleTestConnection = jest.fn() - render( -
- -
- ) - await act(() => { - fireEvent.click(screen.getByTestId('tls-required-checkbox')) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - await act(() => { - fireEvent.click(testConnectionBtn) - }) - expect(handleTestConnection).toBeCalledWith( - expect.objectContaining({ - tlsClientAuthRequired: ['on'], - }) - ) - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - tlsClientAuthRequired: ['on'], - }) - ) - }) - - it('should render fields for add new CA with required tls auth and change them properly', async () => { - const handleSubmit = jest.fn() - const { container } = render( -
- -
- ) - - expect(screen.getByTestId('select-cert')).toBeInTheDocument() - - await act(() => { - fireEvent.click(screen.getByTestId('select-cert')) - }) - - await act(() => { - fireEvent.click( - container.querySelectorAll('.euiContextMenuItem__text')[0] || document - ) - }) - - expect(screen.getByTestId('new-tsl-cert-pair-name')).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId('new-tsl-cert-pair-name'), { - target: { value: '123' }, - }) - }) - - expect(screen.getByTestId('new-tls-client-cert')).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId('new-tls-client-cert'), { - target: { value: '321' }, - }) - }) - - expect(screen.getByTestId('new-tls-client-cert-key')).toBeInTheDocument() - await act(() => { - fireEvent.change(screen.getByTestId('new-tls-client-cert-key'), { - target: { value: '231' }, - }) - }) - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - - await act(() => { - fireEvent.click(submitBtn) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - newTlsClientCert: '321', - newTlsCertPairName: '123', - newTlsClientKey: '231', - }) - ) - }) - - it('should render clone mode btn', () => { - render( - - ) - expect(screen.getByTestId('clone-db-btn')).toBeTruthy() - }) - - describe('should render proper fields with Clone mode', () => { - it('should render proper fields for standalone db', () => { - render( - - ) - const fieldsTestIds = ['host', 'port', 'username', 'password', 'showDb', 'tls'] - fieldsTestIds.forEach((id) => { - expect(screen.getByTestId(id)).toBeTruthy() - }) - }) - - it('should render proper fields for sentinel db', () => { - render( - - ) - const fieldsTestIds = [ - 'name', - 'primary-group', - 'sentinel-mater-username', - 'sentinel-master-password', - 'host', - 'port', - 'username', - 'password', - 'showDb', - 'tls' - ] - fieldsTestIds.forEach((id) => { - expect(screen.getByTestId(id)).toBeTruthy() - }) - }) - - it('should render selected logical database with proper db index', () => { - render( - - ) - expect(screen.getByTestId('showDb')).toBeChecked() - expect(screen.getByTestId('db')).toHaveValue('5') - }) - - it('should render proper database alias', () => { - render( - - ) - expect(screen.getByTestId('db-alias')).toHaveTextContent('Clone ') - }) - - it('should render proper default values for standalone', () => { - render( - - ) - expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') - expect(screen.getByTestId('port')).toHaveValue('6379') - expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') - }) - - it('should render proper default values for sentinel', () => { - render( - - ) - expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') - expect(screen.getByTestId('port')).toHaveValue('26379') - }) - }) - - it('should change Use SSH checkbox', async () => { - const handleSubmit = jest.fn() - render( -
- -
- ) - - fireEvent.click(screen.getByTestId('use-ssh')) - - expect(screen.getByTestId('use-ssh')).toBeChecked() - }) - - it('should not render Use SSH checkbox for redis stack buidlType', async () => { - const handleSubmit = jest.fn() - render( -
- -
- ) - - expect(screen.queryByTestId('use-ssh')).not.toBeInTheDocument() - }) - - it('should change Use SSH checkbox and show proper fields for password radio', async () => { - const handleSubmit = jest.fn() - render( -
- -
- ) - - fireEvent.click(screen.getByTestId('use-ssh')) - - expect(screen.getByTestId('sshHost')).toBeInTheDocument() - expect(screen.getByTestId('sshPort')).toBeInTheDocument() - expect(screen.getByTestId('sshPort')).toHaveValue('22') - expect(screen.getByTestId('sshPassword')).toBeInTheDocument() - expect(screen.queryByTestId('sshPrivateKey')).not.toBeInTheDocument() - expect(screen.queryByTestId('sshPassphrase')).not.toBeInTheDocument() - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - expect(submitBtn).toBeDisabled() - }) - - it('should change Use SSH checkbox and show proper fields for passphrase radio', async () => { - const handleSubmit = jest.fn() - const { container } = render( -
- -
- ) - - await act(() => { - fireEvent.click(screen.getByTestId('use-ssh')) - fireEvent.click( - container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement - ) - }) - - expect(screen.getByTestId('sshHost')).toBeInTheDocument() - expect(screen.getByTestId('sshPort')).toBeInTheDocument() - expect(screen.getByTestId('sshPort')).toHaveValue('22') - expect(screen.queryByTestId('sshPassword')).not.toBeInTheDocument() - expect(screen.getByTestId('sshPrivateKey')).toBeInTheDocument() - expect(screen.getByTestId('sshPassphrase')).toBeInTheDocument() - - const submitBtn = screen.getByTestId(BTN_SUBMIT) - expect(submitBtn).toBeDisabled() - }) - - it('should be proper validation for ssh via ssh password', async () => { - const handleSubmit = jest.fn() - render( -
- -
- ) - - expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - - await act(() => { - fireEvent.click(screen.getByTestId('use-ssh')) - }) - - expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - - await act(() => { - fireEvent.change( - screen.getByTestId('sshHost'), - { target: { value: 'localhost' } } - ) - }) - - expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - - await act(() => { - fireEvent.change( - screen.getByTestId('sshUsername'), - { target: { value: 'username' } } - ) - }) - - expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - }) - - it('should be proper validation for ssh via ssh passphrase', async () => { - const handleSubmit = jest.fn() - const { container } = render( -
- -
- ) - - expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - - await act(() => { - fireEvent.click(screen.getByTestId('use-ssh')) - fireEvent.click( - container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement - ) - }) - - expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - - await act(() => { - fireEvent.change( - screen.getByTestId('sshHost'), - { target: { value: 'localhost' } } - ) - fireEvent.change( - screen.getByTestId('sshUsername'), - { target: { value: 'username' } } - ) - }) - - expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - - await act(() => { - fireEvent.change( - screen.getByTestId('sshPrivateKey'), - { target: { value: 'PRIVATEKEY' } } - ) - }) - - expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - }) - - it('should call submit btn with proper fields', async () => { - const handleSubmit = jest.fn() - render( -
- -
- ) - - await act(() => { - fireEvent.click(screen.getByTestId('use-ssh')) - }) - - await act(() => { - fireEvent.change( - screen.getByTestId('sshHost'), - { target: { value: 'localhost' } } - ) - - fireEvent.change( - screen.getByTestId('sshPort'), - { target: { value: '1771' } } - ) - - fireEvent.change( - screen.getByTestId('sshUsername'), - { target: { value: 'username' } } - ) - - fireEvent.change( - screen.getByTestId('sshPassword'), - { target: { value: '123' } } - ) - }) - - await act(() => { - fireEvent.click(screen.getByTestId(BTN_SUBMIT)) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - sshHost: 'localhost', - sshPort: '1771', - sshUsername: 'username', - sshPassword: '123', - }) - ) - }) - - it('should call submit btn with proper fields via passphrase', async () => { - const handleSubmit = jest.fn() - const { container } = render( -
- -
- ) - - await act(() => { - fireEvent.click(screen.getByTestId('use-ssh')) - fireEvent.click( - container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement - ) - }) - - await act(() => { - fireEvent.change( - screen.getByTestId('sshHost'), - { target: { value: 'localhost' } } - ) - - fireEvent.change( - screen.getByTestId('sshPort'), - { target: { value: '1771' } } - ) - - fireEvent.change( - screen.getByTestId('sshUsername'), - { target: { value: 'username' } } - ) - - fireEvent.change( - screen.getByTestId('sshPrivateKey'), - { target: { value: '123444' } } - ) - - fireEvent.change( - screen.getByTestId('sshPassphrase'), - { target: { value: '123444' } } - ) - }) - - await act(() => { - fireEvent.click(screen.getByTestId(BTN_SUBMIT)) - }) - - expect(handleSubmit).toBeCalledWith( - expect.objectContaining({ - sshHost: 'localhost', - sshPort: '1771', - sshUsername: 'username', - sshPrivateKey: '123444', - sshPassphrase: '123444', - }) - ) - }) - - it('should render password input with 10_000 length limit', () => { - render( - - ) - - expect(screen.getByTestId('password')).toHaveAttribute('maxLength', '10000') - }) - - it('should render security fields with proper attributes', () => { - render( - - ) - - expect(screen.getByTestId('password')).toHaveAttribute('value', '••••••••••••') - expect(screen.getByTestId('password')).toHaveAttribute('type', 'password') - expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '••••••••••••') - expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('type', 'password') - - fireEvent.focus(screen.getByTestId('password')) - fireEvent.focus(screen.getByTestId('sshPassphrase')) - - expect(screen.getByTestId('password')).toHaveAttribute('value', '') - expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '') - }) - - it('should render ssh password with proper attributes', () => { - render( - - ) - - expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '••••••••••••') - expect(screen.getByTestId('sshPassword')).toHaveAttribute('type', 'password') - - fireEvent.focus(screen.getByTestId('sshPassword')) - - expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '') - }) - - it('should render ssh password input with 10_000 length limit', () => { - render( - - ) - - expect(screen.getByTestId('sshPassword')).toHaveAttribute('maxLength', '10000') - }) - - describe('timeout', () => { - it('should render timeout input with 7 length limit and 1_000_000 value', () => { - render( - - ) - - expect(screen.getByTestId('timeout')).toBeInTheDocument() - expect(screen.getByTestId('timeout')).toHaveAttribute('maxLength', '7') - - fireEvent.change( - screen.getByTestId('timeout'), - { target: { value: '2000000' } } - ) - - expect(screen.getByTestId('timeout')).toHaveAttribute('value', '1000000') - }) - - it('should put only numbers', () => { - render( - - ) - - fireEvent.change( - screen.getByTestId('timeout'), - { target: { value: '11a2EU$#@' } } - ) - - expect(screen.getByTestId('timeout')).toHaveAttribute('value', '112') - }) - }) - - describe('cloud', () => { - it('some fields should be readonly if instance data source from cloud', () => { - (appRedirectionSelector as jest.Mock).mockImplementation(() => ({ - action: UrlHandlingActions.Connect, - })) - - const { queryByTestId } = render( - - ) - - expect(queryByTestId('connection-type')).not.toBeInTheDocument() - expect(queryByTestId('host')).not.toBeInTheDocument() - expect(queryByTestId('port')).not.toBeInTheDocument() - expect(queryByTestId('db-info-port')).toBeInTheDocument() - expect(queryByTestId('db-info-host')).toBeInTheDocument() - }) - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx deleted file mode 100644 index 27c019c9cf..0000000000 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ /dev/null @@ -1,783 +0,0 @@ -import { - EuiButton, - EuiCollapsibleNavGroup, - EuiForm, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiToolTip, - keys, -} from '@elastic/eui' -import { FormikErrors, useFormik } from 'formik' -import { isEmpty, pick, toString } from 'lodash' -import React, { useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' -import { useDispatch, useSelector } from 'react-redux' -import { useHistory } from 'react-router' - -import { PageNames, Pages } from 'uiSrc/constants' -import validationErrors from 'uiSrc/constants/validationErrors' -import DatabaseAlias from 'uiSrc/pages/home/components/DatabaseAlias' -import { useResizableFormField } from 'uiSrc/services' -import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' -import { resetKeys } from 'uiSrc/slices/browser/keys' -import { - changeInstanceAliasAction, - checkConnectToInstanceAction, - resetInstanceUpdateAction, - setConnectedInstanceId, -} from 'uiSrc/slices/instances/instances' -import { ConnectionType, InstanceType, } from 'uiSrc/slices/interfaces' -import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { Nullable, getDiffKeysOfObjectValues, isRediStack } from 'uiSrc/utils' -import { BuildType } from 'uiSrc/constants/env' -import { appRedirectionSelector } from 'uiSrc/slices/app/url-handling' -import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' - -import { - ADD_NEW_CA_CERT, - NO_CA_CERT, - ADD_NEW, - fieldDisplayNames, - SshPassType, - DEFAULT_TIMEOUT, - NONE, -} from './constants' - -import { DbConnectionInfo, ISubmitButton } from './interfaces' -import { - DbIndex, - DbInfo, - MessageSentinel, - MessageStandalone, - TlsDetails, - DatabaseForm, - DbCompressor -} from './form-components' -import { - DbInfoSentinel, - PrimaryGroupSentinel, - SentinelHostPort, - SentinelMasterDatabase, -} from './form-components/sentinel' -import SSHDetails from './form-components/SSHDetails' -import { LoadingDatabaseText, SubmitBtnText, TitleDatabaseText, } from '../InstanceFormWrapper' - -export interface Props { - width: number - isResizablePanel?: boolean - formFields: DbConnectionInfo - submitButtonText?: SubmitBtnText - titleText?: TitleDatabaseText - loading: boolean - buildType?: BuildType - instanceType: InstanceType - loadingMsg: LoadingDatabaseText - isEditMode: boolean - isCloneMode: boolean - setIsCloneMode: (value: boolean) => void - initialValues: DbConnectionInfo - onSubmit: (values: DbConnectionInfo) => void - onTestConnection: (values: DbConnectionInfo) => void - updateEditingName: (name: string) => void - onHostNamePaste: (content: string) => boolean - onClose?: () => void - onAliasEdited?: (value: string) => void - setErrorMsgRef?: (database: HTMLDivElement | null) => void - urlHandlingAction?: Nullable -} - -const getInitFieldsDisplayNames = ({ host, port, name, instanceType }: any) => { - if (!host || !port) { - if (!name && instanceType !== InstanceType.Sentinel) { - return pick(fieldDisplayNames, ['host', 'port', 'name']) - } - return pick(fieldDisplayNames, ['host', 'port']) - } - return {} -} - -const getDefaultHost = () => '127.0.0.1' -const getDefaultPort = (instanceType: InstanceType) => (instanceType === InstanceType.Sentinel ? '26379' : '6379') - -const AddStandaloneForm = (props: Props) => { - const { - formFields: { - id, - host, - name, - port, - tls, - db = null, - compressor = NONE, - nameFromProvider, - sentinelMaster, - connectionType, - nodes = null, - tlsClientAuthRequired, - certificates, - selectedTlsClientCertId = '', - verifyServerTlsCert, - caCertificates, - selectedCaCertName, - username, - password, - timeout, - modules, - sentinelMasterPassword, - sentinelMasterUsername, - servername, - provider, - ssh, - sshPassType = SshPassType.Password, - sshOptions, - version, - }, - initialValues: initialValuesProp, - width, - onClose, - onSubmit, - onTestConnection, - onHostNamePaste, - submitButtonText, - instanceType, - buildType, - loading, - isEditMode, - isCloneMode, - setIsCloneMode, - onAliasEdited, - } = props - - const { contextInstanceId, lastPage } = useSelector(appContextSelector) - const { action } = useSelector(appRedirectionSelector) - - const prepareInitialValues = () => ({ - host: host ?? getDefaultHost(), - port: port ? port.toString() : getDefaultPort(instanceType), - timeout: timeout ? timeout.toString() : toString(DEFAULT_TIMEOUT / 1_000), - name: name ?? `${getDefaultHost()}:${getDefaultPort(instanceType)}`, - username, - password, - tls, - db, - compressor, - modules, - showDb: !!db, - showCompressor: compressor !== NONE, - sni: !!servername, - servername, - newCaCert: '', - newCaCertName: '', - selectedCaCertName, - tlsClientAuthRequired, - verifyServerTlsCert, - newTlsCertPairName: '', - selectedTlsClientCertId, - newTlsClientCert: '', - newTlsClientKey: '', - sentinelMasterName: sentinelMaster?.name || '', - sentinelMasterUsername, - sentinelMasterPassword, - ssh, - sshPassType, - sshHost: sshOptions?.host ?? '', - sshPort: sshOptions?.port ?? 22, - sshUsername: sshOptions?.username ?? '', - sshPassword: sshOptions?.password ?? '', - sshPrivateKey: sshOptions?.privateKey ?? '', - sshPassphrase: sshOptions?.passphrase ?? '' - }) - - const [initialValues, setInitialValues] = useState(prepareInitialValues()) - - const [errors, setErrors] = useState>( - getInitFieldsDisplayNames({ host, port, name, instanceType }) - ) - - useEffect(() => { - const values = prepareInitialValues() - - setInitialValues(values) - formik.setValues(values) - }, [initialValuesProp, isCloneMode]) - - const history = useHistory() - const dispatch = useDispatch() - - const formRef = useRef(null) - - const submitIsDisable = () => !isEmpty(errors) - const isFromCloud = action === UrlHandlingActions.Connect - - const validate = (values: DbConnectionInfo) => { - const errs: FormikErrors = {} - - if (!values.host) { - errs.host = fieldDisplayNames.host - } - if (!values.port) { - errs.port = fieldDisplayNames.port - } - - if (!values.name && instanceType !== InstanceType.Sentinel) { - errs.name = fieldDisplayNames.name - } - - if ( - values.tls - && values.verifyServerTlsCert - && values.selectedCaCertName === NO_CA_CERT - ) { - errs.selectedCaCertName = fieldDisplayNames.selectedCaCertName - } - - if ( - values.tls - && values.selectedCaCertName === ADD_NEW_CA_CERT - && values.newCaCertName === '' - ) { - errs.newCaCertName = fieldDisplayNames.newCaCertName - } - - if ( - values.tls - && values.selectedCaCertName === ADD_NEW_CA_CERT - && values.newCaCert === '' - ) { - errs.newCaCert = fieldDisplayNames.newCaCert - } - - if ( - values.tls - && values.sni - && values.servername === '' - ) { - errs.servername = fieldDisplayNames.servername - } - - if ( - values.tls - && values.tlsClientAuthRequired - && values.selectedTlsClientCertId === ADD_NEW - ) { - if (values.newTlsCertPairName === '') { - errs.newTlsCertPairName = fieldDisplayNames.newTlsCertPairName - } - if (values.newTlsClientCert === '') { - errs.newTlsClientCert = fieldDisplayNames.newTlsClientCert - } - if (values.newTlsClientKey === '') { - errs.newTlsClientKey = fieldDisplayNames.newTlsClientKey - } - } - - if (isCloneMode && connectionType === ConnectionType.Sentinel && !values.sentinelMasterName) { - errs.sentinelMasterName = fieldDisplayNames.sentinelMasterName - } - - if (values.ssh) { - if (!values.sshHost) { - errs.sshHost = fieldDisplayNames.sshHost - } - if (!values.sshPort) { - errs.sshPort = fieldDisplayNames.sshPort - } - if (!values.sshUsername) { - errs.sshUsername = fieldDisplayNames.sshUsername - } - if (values.sshPassType === SshPassType.PrivateKey && !values.sshPrivateKey) { - errs.sshPrivateKey = fieldDisplayNames.sshPrivateKey - } - } - - setErrors(errs) - return errs - } - - const formik = useFormik({ - initialValues, - validate, - enableReinitialize: true, - onSubmit: (values: any) => { - if (isCloneMode) { - const diffKeys = getDiffKeysOfObjectValues(formik.initialValues, values) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CONFIRMED, - eventData: { - fieldsModified: diffKeys - } - }) - } - onSubmit(values) - }, - }) - - const [flexGroupClassName, flexItemClassName] = useResizableFormField( - formRef, - width - ) - - const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === keys.ENTER && !submitIsDisable()) { - // event. - formik.submitForm() - } - } - - useEffect(() => - // componentWillUnmount - () => { - if (isEditMode) { - dispatch(resetInstanceUpdateAction()) - } - }, - []) - - const handleCheckConnectToInstance = () => { - const modulesSummary = getRedisModulesSummary(modules) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_OPEN_DATABASE_BUTTON_CLICKED, - eventData: { - databaseId: id, - provider, - ...modulesSummary, - } - }) - dispatch(checkConnectToInstanceAction(id, connectToInstance)) - } - - const handleCloneDatabase = () => { - setIsCloneMode(true) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_REQUESTED, - eventData: { - databaseId: id - } - }) - } - - const handleBackCloneDatabase = () => { - setIsCloneMode(false) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CANCELLED, - eventData: { - databaseId: id - } - }) - } - - const handleTestConnectionDatabase = () => { - onTestConnection(formik.values) - } - - const handleChangeDatabaseAlias = ( - value: string, - onSuccess?: () => void, - onFail?: () => void - ) => { - dispatch(changeInstanceAliasAction( - id, - value, - () => { - onAliasEdited?.(value) - onSuccess?.() - }, - onFail - )) - } - - const connectToInstance = () => { - if (contextInstanceId && contextInstanceId !== id) { - dispatch(resetKeys()) - dispatch(setAppContextInitialState()) - } - dispatch(setConnectedInstanceId(id ?? '')) - - if (lastPage === PageNames.workbench && contextInstanceId === id) { - history.push(Pages.workbench(id)) - return - } - history.push(Pages.browser(id)) - } - - const getSubmitButtonContent = (submitIsDisabled?: boolean) => { - const maxErrorsCount = 5 - const errorsArr = Object.values(errors).map((err) => [ - err, -
, - ]) - - if (errorsArr.length > maxErrorsCount) { - errorsArr.splice(maxErrorsCount, errorsArr.length, ['...']) - } - return submitIsDisabled ? ( - {errorsArr} - ) : null - } - - const SubmitButton = ({ - text = '', - onClick, - submitIsDisabled, - }: ISubmitButton) => ( - - - {text} - - - ) - - const Footer = () => { - const footerEl = document.getElementById('footerDatabaseForm') - - if (footerEl) { - return ReactDOM.createPortal( - - - {instanceType !== InstanceType.Sentinel && ( - - - Test Connection - - - )} - - - - - {onClose && ( - - Cancel - - )} - - - - , - footerEl - ) - } - return null - } - - return ( -
- {isEditMode && name && ( -
- -
- )} -
- {!isEditMode && instanceType === InstanceType.Standalone && !isFromCloud && ( - <> - -
- - )} - {!isEditMode && instanceType === InstanceType.Sentinel && ( - <> - -
- - )} - {!isEditMode && !isFromCloud && ( - - - {instanceType !== InstanceType.Sentinel && ( - - )} - {instanceType !== InstanceType.Sentinel && ( - - )} - - {instanceType !== InstanceType.Sentinel && buildType !== BuildType.RedisStack && ( - - )} - - )} - {(isEditMode || isCloneMode || isFromCloud) && connectionType !== ConnectionType.Sentinel && ( - <> - {!isCloneMode && ( - - )} - - - {isCloneMode && ( - - )} - - - {buildType !== BuildType.RedisStack && ( - - )} - - - )} - {(isEditMode || isCloneMode) && connectionType === ConnectionType.Sentinel && ( - <> - - {!isCloneMode && ( - <> - - - - - - - - - - - - - - )} - {isCloneMode && ( - <> - - - - - - - - - - - - - )} - - - )} -
-
-
- ) -} - -export default AddStandaloneForm diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/index.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/index.ts deleted file mode 100644 index a9a9fd384e..0000000000 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import InstanceForm from './InstanceForm' - -export * from './InstanceForm' - -export default InstanceForm diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx deleted file mode 100644 index bc5a0b77c2..0000000000 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { cloneDeep, toString } from 'lodash' -import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' -import { Instance } from 'uiSrc/slices/interfaces' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' -import { defaultInstanceChanging } from 'uiSrc/slices/instances/instances' -import InstanceFormWrapper, { Props } from './InstanceFormWrapper' -import InstanceForm, { Props as InstanceProps, } from './InstanceForm/InstanceForm' - -const mockedProps = mock() -const mockedEditedInstance: Instance = { - name: 'name', - host: 'host', - port: 123, - timeout: 10_000, - id: '123', - modules: [], - tls: true, - caCert: { id: 'zxc' }, - clientCert: { id: 'zxc' }, -} - -const mockedValues = { - newCaCert: '', - tls: true, - newCaCertName: '', - selectedCaCertName: '', - tlsClientAuthRequired: false, - verifyServerTlsCert: true, - newTlsCertPairName: '', - selectedTlsClientCertId: '', - newTlsClientCert: '', - newTlsClientKey: '', -} - -jest.mock('./InstanceForm/InstanceForm', () => ({ - __esModule: true, - namedExport: jest.fn(), - default: jest.fn(), -})) - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - updateInstanceAction: () => jest.fn, - testInstanceStandaloneAction: () => jest.fn, - instancesSelector: jest.fn().mockReturnValue({ loadingChanging: false }), -})) - -jest.mock('uiSrc/slices/instances/clientCerts', () => ({ - clientCertsSelector: () => jest.fn().mockReturnValue({ data: [] }), - fetchClientCerts: jest.fn, -})) - -jest.mock('uiSrc/slices/instances/caCerts', () => ({ - caCertsSelector: () => jest.fn().mockReturnValue({ data: [] }), - fetchCaCerts: () => jest.fn, -})) - -jest.mock('uiSrc/slices/instances/sentinel', () => ({ - sentinelSelector: () => jest.fn().mockReturnValue({ loading: false }), - fetchMastersSentinelAction: () => jest.fn, -})) - -let store: typeof mockedStore -beforeEach(() => { - cleanup() - store = cloneDeep(mockedStore) - store.clearActions() -}) - -const MockInstanceForm = (props: InstanceProps) => ( -
- - - - -
-) - -describe('InstanceFormWrapper', () => { - beforeAll(() => { - InstanceForm.mockImplementation(MockInstanceForm) - }) - it('should render', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should send prop timeout / 1_000 (in seconds)', () => { - expect( - render( - - ) - ).toBeTruthy() - - expect(InstanceForm).toHaveBeenCalledWith( - expect.objectContaining({ - formFields: expect.objectContaining({ - timeout: toString(mockedEditedInstance?.timeout / 1_000), - }), - }), - {}, - ) - }) - - it('should call onClose', () => { - const onClose = jest.fn() - render( - - ) - fireEvent.click(screen.getByTestId('close-btn')) - expect(onClose).toBeCalled() - }) - - it('should submit with editMode', () => { - const component = render( - - ) - fireEvent.click(screen.getByTestId('submit-form-btn')) - expect(component).toBeTruthy() - }) - - it('should call onHostNamePaste', () => { - const component = render( - - ) - fireEvent.click(screen.getByTestId('paste-hostName-btn')) - expect(component).toBeTruthy() - }) - - it('should call proper telemetry events after click test connection', () => { - const sendEventTelemetryMock = jest.fn() - - sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) - - render( - - ) - fireEvent.click(screen.getByTestId('btn-test-connection')) - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_TEST_CONNECTION_CLICKED, - }) - sendEventTelemetry.mockRestore() - }) - - it('should call proper actions onSubmit with url handling', () => { - render( - - ) - fireEvent.click(screen.getByTestId('submit-form-btn')) - expect(store.getActions()).toEqual([ - defaultInstanceChanging() - ]) - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/index.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/index.ts deleted file mode 100644 index df91655e04..0000000000 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import InstanceFormWrapper from './InstanceFormWrapper' - -export default InstanceFormWrapper diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/index.ts new file mode 100644 index 0000000000..0c05734c8c --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/index.ts @@ -0,0 +1,3 @@ +import CloudConnectionForm from './CloudConnectionForm' + +export default CloudConnectionForm diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx index 5436af2f92..9da568212c 100644 --- a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx @@ -8,7 +8,7 @@ import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import CloudConnectionForm from './CloudConnectionForm/CloudConnectionForm' +import CloudConnectionForm from './CloudConnectionForm' export interface Props { width: number diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/index.ts b/redisinsight/ui/src/pages/home/components/CloudConnection/index.ts new file mode 100644 index 0000000000..90a70f2f19 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/CloudConnection/index.ts @@ -0,0 +1,3 @@ +import CloudConnectionFormWrapper from './CloudConnectionFormWrapper' + +export default CloudConnectionFormWrapper diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/index.ts new file mode 100644 index 0000000000..44ec2d4262 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/index.ts @@ -0,0 +1,3 @@ +import ClusterConnectionForm from './ClusterConnectionForm' + +export default ClusterConnectionForm diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx index 2f2d590be5..5d72654536 100644 --- a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx @@ -1,17 +1,17 @@ import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { ConnectionString } from 'connection-string' import { useHistory } from 'react-router-dom' import { clusterSelector, fetchInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' -import { REDIS_URI_SCHEMES, Pages } from 'uiSrc/constants' +import { Pages } from 'uiSrc/constants' import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' -import { ICredentialsRedisCluster } from 'uiSrc/slices/interfaces' +import { ICredentialsRedisCluster, InstanceType } from 'uiSrc/slices/interfaces' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { autoFillFormDetails } from 'uiSrc/pages/home/utils' import ClusterConnectionForm from './ClusterConnectionForm/ClusterConnectionForm' @@ -70,44 +70,9 @@ const ClusterConnectionFormWrapper = ({ onClose, width }: Props) => { history.push(Pages.redisEnterpriseAutodiscovery) } - const autoFillFormDetails = (content: string): boolean => { - try { - const details = new ConnectionString(content) - - /* If a protocol exists, it should be a redis protocol */ - if (details.protocol && !REDIS_URI_SCHEMES.includes(details.protocol)) return false - /* - * Auto fill logic: - * 1) If the port is parsed, we are sure that the user has indeed copied a connection string. - * '172.18.0.2:12000' => {host: '172,18.0.2', port: 12000} - * 'redis-12000.cluster.local:12000' => {host: 'redis-12000.cluster.local', port: 12000} - * 'lorem ipsum' => {host: undefined, port: undefined} - * 2) If the port is `undefined` but a redis URI scheme is present as protocol, we follow - * the "Scheme semantics" as mentioned in the official URI schemes. - * i) redis:// - https://www.iana.org/assignments/uri-schemes/prov/redis - * ii) rediss:// - https://www.iana.org/assignments/uri-schemes/prov/rediss - */ - if ( - details.port !== undefined - || REDIS_URI_SCHEMES.includes(details.protocol || '') - ) { - setInitialValues({ - host: details.hostname || initialValues.host || 'localhost', - port: `${details.port || initialValues.port || 9443}`, - username: details.user || '', - password: details.password || '', - }) - /* - * auto fill was successfull so return true - */ - return true - } - } catch (err) { - /* The pasted content is not a connection URI so ignore. */ - return false - } - return false - } + const handlePostHostName = (content: string) => ( + autoFillFormDetails(content, initialValues, setInitialValues, InstanceType.RedisEnterpriseCluster) + ) return (
@@ -117,7 +82,7 @@ const ClusterConnectionFormWrapper = ({ onClose, width }: Props) => { username={credentials?.username ?? ''} password={credentials?.password ?? ''} initialValues={initialValues} - onHostNamePaste={autoFillFormDetails} + onHostNamePaste={handlePostHostName} flexGroupClassName={flexGroupClassName} flexItemClassName={flexItemClassName} onClose={onClose} diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/index.ts b/redisinsight/ui/src/pages/home/components/ClusterConnection/index.ts new file mode 100644 index 0000000000..405d374c5f --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/ClusterConnection/index.ts @@ -0,0 +1,3 @@ +import ClusterConnectionFormWrapper from './ClusterConnectionFormWrapper' + +export default ClusterConnectionFormWrapper diff --git a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx b/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx index 207270b79a..24faa0fe03 100644 --- a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx @@ -28,7 +28,7 @@ import styles from './styles.module.scss' export interface Props { alias: string - database?: Nullable + database?: Nullable onOpen: () => void onClone: () => void onCloneBack: () => void diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/index.ts b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/index.ts new file mode 100644 index 0000000000..06b6eca03c --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/index.ts @@ -0,0 +1,3 @@ +import DatabasesList from './DatabasesList' + +export default DatabasesList diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx index 9cffb5a5a7..ea442a4d66 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx @@ -11,7 +11,7 @@ import { RootState, store } from 'uiSrc/slices/store' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' import DatabasesListWrapper, { Props } from './DatabasesListWrapper' -import DatabasesList, { Props as DatabasesListProps } from './DatabasesList/DatabasesList' +import DatabasesList, { Props as DatabasesListProps } from './DatabasesList' const mockedProps = mock() diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx index c163666a4e..54c6ca183b 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx @@ -40,7 +40,7 @@ import RediStackLightLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLog import RediStackDarkLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoDark.svg' import { ReactComponent as CloudLinkIcon } from 'uiSrc/assets/img/oauth/cloud_link.svg' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' -import DatabasesList from './DatabasesList/DatabasesList' +import DatabasesList from './DatabasesList' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/index.ts b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/index.ts new file mode 100644 index 0000000000..5685d6dfb3 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/index.ts @@ -0,0 +1,3 @@ +import DatabasesListWrapper from './DatabasesListWrapper' + +export default DatabasesListWrapper diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx b/redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx similarity index 97% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx rename to redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx index 9ed4aa02a0..c64a440d57 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx @@ -16,18 +16,18 @@ import { SECURITY_FIELD } from 'uiSrc/constants' import { appInfoSelector } from 'uiSrc/slices/app/info' import { handlePasteHostName, MAX_PORT_NUMBER, MAX_TIMEOUT_NUMBER, selectOnFocus, validateField, validatePortNumber, validateTimeoutNumber } from 'uiSrc/utils' import { ConnectionType, InstanceType } from 'uiSrc/slices/interfaces' -import { DbConnectionInfo } from '../interfaces' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' export interface Props { flexGroupClassName?: string flexItemClassName?: string formik: FormikProps - isEditMode: boolean - isCloneMode: boolean + isEditMode?: boolean + isCloneMode?: boolean onHostNamePaste: (content: string) => boolean instanceType: InstanceType connectionType?: ConnectionType - isFromCloud: boolean + isFromCloud?: boolean } const DatabaseForm = (props: Props) => { @@ -35,8 +35,8 @@ const DatabaseForm = (props: Props) => { flexGroupClassName = '', flexItemClassName = '', formik, - isEditMode, - isCloneMode, + isEditMode = false, + isCloneMode = false, onHostNamePaste, instanceType, connectionType, diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbCompressor.tsx b/redisinsight/ui/src/pages/home/components/Form/DbCompressor.tsx similarity index 96% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbCompressor.tsx rename to redisinsight/ui/src/pages/home/components/Form/DbCompressor.tsx index 320811ea2c..6bbe25cb4a 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbCompressor.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/DbCompressor.tsx @@ -12,8 +12,8 @@ import cx from 'classnames' import { FormikProps } from 'formik' import { KeyValueCompressor } from 'uiSrc/constants' -import { DbConnectionInfo } from '../interfaces' -import { NONE } from '../constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' +import { NONE } from 'uiSrc/pages/home/constants' import styles from '../styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx b/redisinsight/ui/src/pages/home/components/Form/DbIndex.tsx similarity index 97% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx rename to redisinsight/ui/src/pages/home/components/Form/DbIndex.tsx index 0681dbd941..c209bb3be7 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/DbIndex.tsx @@ -5,7 +5,7 @@ import { FormikProps } from 'formik' import { validateNumber } from 'uiSrc/utils' -import { DbConnectionInfo } from '../interfaces' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import styles from '../styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbInfo.tsx b/redisinsight/ui/src/pages/home/components/Form/DbInfo.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbInfo.tsx rename to redisinsight/ui/src/pages/home/components/Form/DbInfo.tsx diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/Messages.tsx b/redisinsight/ui/src/pages/home/components/Form/Messages.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/Messages.tsx rename to redisinsight/ui/src/pages/home/components/Form/Messages.tsx diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx b/redisinsight/ui/src/pages/home/components/Form/SSHDetails.tsx similarity index 98% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx rename to redisinsight/ui/src/pages/home/components/Form/SSHDetails.tsx index 98ba411718..9a7ef33918 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/SSHDetails.tsx @@ -23,8 +23,8 @@ import { } from 'uiSrc/utils' import { SECURITY_FIELD } from 'uiSrc/constants' -import { SshPassType } from '../constants' -import { DbConnectionInfo } from '../interfaces' +import { SshPassType } from 'uiSrc/pages/home/constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import styles from '../styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx b/redisinsight/ui/src/pages/home/components/Form/TlsDetails.tsx similarity index 99% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx rename to redisinsight/ui/src/pages/home/components/Form/TlsDetails.tsx index 9f5b6c8884..dfeef17320 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/TlsDetails.tsx @@ -18,8 +18,8 @@ import { validateCertName, validateField } from 'uiSrc/utils' import { ADD_NEW_CA_CERT, NO_CA_CERT -} from '../constants' -import { DbConnectionInfo } from '../interfaces' +} from 'uiSrc/pages/home/constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import styles from '../styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/index.ts b/redisinsight/ui/src/pages/home/components/Form/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/index.ts rename to redisinsight/ui/src/pages/home/components/Form/index.ts diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/DbInfoSentinel.tsx b/redisinsight/ui/src/pages/home/components/Form/sentinel/DbInfoSentinel.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/DbInfoSentinel.tsx rename to redisinsight/ui/src/pages/home/components/Form/sentinel/DbInfoSentinel.tsx diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx b/redisinsight/ui/src/pages/home/components/Form/sentinel/PrimaryGroupSentinel.tsx similarity index 96% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx rename to redisinsight/ui/src/pages/home/components/Form/sentinel/PrimaryGroupSentinel.tsx index f913d8ee1a..c867a9e90b 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/sentinel/PrimaryGroupSentinel.tsx @@ -2,7 +2,7 @@ import React from 'react' import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui' import { FormikProps } from 'formik' -import { DbConnectionInfo } from '../../interfaces' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' export interface Props { flexGroupClassName?: string diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelHostPort.tsx b/redisinsight/ui/src/pages/home/components/Form/sentinel/SentinelHostPort.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelHostPort.tsx rename to redisinsight/ui/src/pages/home/components/Form/sentinel/SentinelHostPort.tsx diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx b/redisinsight/ui/src/pages/home/components/Form/sentinel/SentinelMasterDatabase.tsx similarity index 97% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx rename to redisinsight/ui/src/pages/home/components/Form/sentinel/SentinelMasterDatabase.tsx index 9f29f47e9b..00937d6625 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/sentinel/SentinelMasterDatabase.tsx @@ -11,7 +11,7 @@ import { import { FormikProps } from 'formik' import { Nullable } from 'uiSrc/utils' -import { DbConnectionInfo } from '../../interfaces' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import styles from '../../styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/index.ts b/redisinsight/ui/src/pages/home/components/Form/sentinel/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/index.ts rename to redisinsight/ui/src/pages/home/components/Form/sentinel/index.ts diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx new file mode 100644 index 0000000000..5582d422ac --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx @@ -0,0 +1,662 @@ +import { + EuiButton, + EuiCollapsibleNavGroup, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiToolTip, + keys, +} from '@elastic/eui' +import { FormikErrors, useFormik } from 'formik' +import { isEmpty, pick, toString } from 'lodash' +import React, { useEffect, useRef, useState } from 'react' +import ReactDOM from 'react-dom' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory } from 'react-router' + +import { PageNames, Pages } from 'uiSrc/constants' +import validationErrors from 'uiSrc/constants/validationErrors' +import DatabaseAlias from 'uiSrc/pages/home/components/DatabaseAlias' +import { useResizableFormField } from 'uiSrc/services' +import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' +import { resetKeys } from 'uiSrc/slices/browser/keys' +import { + changeInstanceAliasAction, + checkConnectToInstanceAction, + resetInstanceUpdateAction, + setConnectedInstanceId, +} from 'uiSrc/slices/instances/instances' +import { ConnectionType, InstanceType, } from 'uiSrc/slices/interfaces' +import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { getDiffKeysOfObjectValues, isRediStack } from 'uiSrc/utils' +import { BuildType } from 'uiSrc/constants/env' +import { appRedirectionSelector } from 'uiSrc/slices/app/url-handling' +import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' + +import { + fieldDisplayNames, + SshPassType, + DEFAULT_TIMEOUT, + NONE, +} from 'uiSrc/pages/home/constants' +import { getFormErrors, getSubmitButtonContent } from 'uiSrc/pages/home/utils' +import { DbConnectionInfo, ISubmitButton } from 'uiSrc/pages/home/interfaces' +import { + DbIndex, + DbInfo, + MessageStandalone, + TlsDetails, + DatabaseForm, + DbCompressor, + SSHDetails, +} from 'uiSrc/pages/home/components/Form' +import { + DbInfoSentinel, + PrimaryGroupSentinel, + SentinelHostPort, + SentinelMasterDatabase, +} from 'uiSrc/pages/home/components/Form/sentinel' +import { SubmitBtnText } from 'uiSrc/pages/home/components/ManualConnection/ManualConnectionWrapper' + +export interface Props { + width: number + formFields: DbConnectionInfo + submitButtonText?: SubmitBtnText + loading: boolean + buildType?: BuildType + isEditMode: boolean + isCloneMode: boolean + setIsCloneMode: (value: boolean) => void + initialValues: DbConnectionInfo + onSubmit: (values: DbConnectionInfo) => void + onTestConnection: (values: DbConnectionInfo) => void + onHostNamePaste: (content: string) => boolean + onClose?: () => void + onAliasEdited?: (value: string) => void +} + +const getInitFieldsDisplayNames = ({ host, port, name }: any) => { + if ((!host || !port) && !name) { + return pick(fieldDisplayNames, ['host', 'port', 'name']) + } + return {} +} + +const getDefaultHost = () => '127.0.0.1' +const getDefaultPort = () => '6379' + +const ManualConnectionForm = (props: Props) => { + const { + formFields: { + id, + host, + name, + port, + tls, + db = null, + compressor = NONE, + nameFromProvider, + sentinelMaster, + connectionType, + nodes = null, + tlsClientAuthRequired, + certificates, + selectedTlsClientCertId = '', + verifyServerTlsCert, + caCertificates, + selectedCaCertName, + username, + password, + timeout, + modules, + sentinelMasterPassword, + sentinelMasterUsername, + servername, + provider, + ssh, + sshPassType = SshPassType.Password, + sshOptions, + version, + }, + initialValues: initialValuesProp, + width, + onClose, + onSubmit, + onTestConnection, + onHostNamePaste, + submitButtonText, + buildType, + loading, + isEditMode, + isCloneMode, + setIsCloneMode, + onAliasEdited, + } = props + + const { contextInstanceId, lastPage } = useSelector(appContextSelector) + const { action } = useSelector(appRedirectionSelector) + + const prepareInitialValues = () => ({ + host: host ?? getDefaultHost(), + port: port ? port.toString() : getDefaultPort(), + timeout: timeout ? timeout.toString() : toString(DEFAULT_TIMEOUT / 1_000), + name: name ?? `${getDefaultHost()}:${getDefaultPort()}`, + username, + password, + tls, + db, + compressor, + modules, + showDb: !!db, + showCompressor: compressor !== NONE, + sni: !!servername, + servername, + newCaCert: '', + newCaCertName: '', + selectedCaCertName, + tlsClientAuthRequired, + verifyServerTlsCert, + newTlsCertPairName: '', + selectedTlsClientCertId, + newTlsClientCert: '', + newTlsClientKey: '', + sentinelMasterName: sentinelMaster?.name || '', + sentinelMasterUsername, + sentinelMasterPassword, + ssh, + sshPassType, + sshHost: sshOptions?.host ?? '', + sshPort: sshOptions?.port ?? 22, + sshUsername: sshOptions?.username ?? '', + sshPassword: sshOptions?.password ?? '', + sshPrivateKey: sshOptions?.privateKey ?? '', + sshPassphrase: sshOptions?.passphrase ?? '' + }) + + const [initialValues, setInitialValues] = useState(prepareInitialValues()) + + const [errors, setErrors] = useState>( + getInitFieldsDisplayNames({ host, port, name }) + ) + + useEffect(() => { + const values = prepareInitialValues() + + setInitialValues(values) + formik.setValues(values) + }, [initialValuesProp, isCloneMode]) + + const history = useHistory() + const dispatch = useDispatch() + + const formRef = useRef(null) + + const submitIsDisable = () => !isEmpty(errors) + const isFromCloud = action === UrlHandlingActions.Connect + + const validate = (values: DbConnectionInfo) => { + const errs = getFormErrors(values) + setErrors(errs) + return errs + } + + const formik = useFormik({ + initialValues, + validate, + enableReinitialize: true, + onSubmit: (values: any) => { + if (isCloneMode) { + const diffKeys = getDiffKeysOfObjectValues(formik.initialValues, values) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CONFIRMED, + eventData: { + fieldsModified: diffKeys + } + }) + } + onSubmit(values) + }, + }) + + const [flexGroupClassName, flexItemClassName] = useResizableFormField( + formRef, + width + ) + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === keys.ENTER && !submitIsDisable()) { + // event. + formik.submitForm() + } + } + + useEffect(() => + // componentWillUnmount + () => { + if (isEditMode) { + dispatch(resetInstanceUpdateAction()) + } + }, + []) + + const handleCheckConnectToInstance = () => { + const modulesSummary = getRedisModulesSummary(modules) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_OPEN_DATABASE_BUTTON_CLICKED, + eventData: { + databaseId: id, + provider, + ...modulesSummary, + } + }) + dispatch(checkConnectToInstanceAction(id, connectToInstance)) + } + + const handleCloneDatabase = () => { + setIsCloneMode(true) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_REQUESTED, + eventData: { + databaseId: id + } + }) + } + + const handleBackCloneDatabase = () => { + setIsCloneMode(false) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CANCELLED, + eventData: { + databaseId: id + } + }) + } + + const handleTestConnectionDatabase = () => { + onTestConnection(formik.values) + } + + const handleChangeDatabaseAlias = ( + value: string, + onSuccess?: () => void, + onFail?: () => void + ) => { + dispatch(changeInstanceAliasAction( + id, + value, + () => { + onAliasEdited?.(value) + onSuccess?.() + }, + onFail + )) + } + + const connectToInstance = () => { + if (contextInstanceId && contextInstanceId !== id) { + dispatch(resetKeys()) + dispatch(setAppContextInitialState()) + } + dispatch(setConnectedInstanceId(id ?? '')) + + if (lastPage === PageNames.workbench && contextInstanceId === id) { + history.push(Pages.workbench(id)) + return + } + history.push(Pages.browser(id)) + } + + const SubmitButton = ({ + text = '', + onClick, + submitIsDisabled, + }: ISubmitButton) => ( + + + {text} + + + ) + + const Footer = () => { + const footerEl = document.getElementById('footerDatabaseForm') + + if (footerEl) { + return ReactDOM.createPortal( + + + + + Test Connection + + + + + + + {onClose && ( + + Cancel + + )} + + + + , + footerEl + ) + } + return null + } + + return ( +
+ {isEditMode && name && ( +
+ +
+ )} +
+ {!isEditMode && !isFromCloud && ( + <> + +
+ + )} + {!isEditMode && !isFromCloud && ( + + + + + + {buildType !== BuildType.RedisStack && ( + + )} + + )} + {(isEditMode || isCloneMode || isFromCloud) && connectionType !== ConnectionType.Sentinel && ( + <> + {!isCloneMode && ( + + )} + + + {isCloneMode && ( + + )} + + + {buildType !== BuildType.RedisStack && ( + + )} + + + )} + {(isEditMode || isCloneMode) && connectionType === ConnectionType.Sentinel && ( + <> + + {!isCloneMode && ( + <> + + + + + + + + + + + + + + )} + {isCloneMode && ( + <> + + + + + + + + + + + + + )} + + + )} +
+
+
+ ) +} + +export default ManualConnectionForm diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/index.ts new file mode 100644 index 0000000000..600d1f3024 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/index.ts @@ -0,0 +1,3 @@ +import ManualConnectionForm from './ManualConnectionForm' + +export default ManualConnectionForm diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx similarity index 67% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx rename to redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx index 9c71b881e7..429eff83c6 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx @@ -1,6 +1,4 @@ -/* eslint-disable no-nested-ternary */ -import { ConnectionString } from 'connection-string' -import { isUndefined, pick, toNumber, toString, omit } from 'lodash' +import { pick, toNumber, toString, omit } from 'lodash' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router' @@ -13,26 +11,24 @@ import { updateInstanceAction, cloneInstanceAction, } from 'uiSrc/slices/instances/instances' -import { fetchMastersSentinelAction, sentinelSelector, } from 'uiSrc/slices/instances/sentinel' import { Nullable, removeEmpty, getFormUpdates, transformQueryParamsObject } from 'uiSrc/utils' +import { BuildType } from 'uiSrc/constants/env' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { caCertsSelector, fetchCaCerts } from 'uiSrc/slices/instances/caCerts' -import { ConnectionType, Instance, InstanceType, } from 'uiSrc/slices/interfaces' -import { DbType, Pages, REDIS_URI_SCHEMES } from 'uiSrc/constants' +import { ConnectionType, Instance, InstanceType } from 'uiSrc/slices/interfaces' +import { DbType, Pages } from 'uiSrc/constants' import { clientCertsSelector, fetchClientCerts, } from 'uiSrc/slices/instances/clientCerts' import { appInfoSelector } from 'uiSrc/slices/app/info' - import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { getRedirectionPage } from 'uiSrc/utils/routing' -import InstanceForm from './InstanceForm' -import { DbConnectionInfo } from './InstanceForm/interfaces' -import { ADD_NEW, ADD_NEW_CA_CERT, DEFAULT_TIMEOUT, NO_CA_CERT, SshPassType } from './InstanceForm/constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' +import { applyTlSDatabase, applySSHDatabase, autoFillFormDetails } from 'uiSrc/pages/home/utils' +import { ADD_NEW, ADD_NEW_CA_CERT, DEFAULT_TIMEOUT, NO_CA_CERT, SshPassType, SubmitBtnText } from 'uiSrc/pages/home/constants' +import ManualConnectionForm from './ManualConnectionForm' export interface Props { width: number - isResizablePanel?: boolean - instanceType: InstanceType editMode: boolean urlHandlingAction?: Nullable initialValues?: Nullable> @@ -42,18 +38,6 @@ export interface Props { onAliasEdited?: (value: string) => void } -export enum SubmitBtnText { - AddDatabase = 'Add Redis Database', - EditDatabase = 'Apply changes', - ConnectToSentinel = 'Discover database', - CloneDatabase = 'Clone Database' -} - -export enum LoadingDatabaseText { - AddDatabase = 'Adding database...', - EditDatabase = 'Editing database...', -} - export enum TitleDatabaseText { AddDatabase = 'Add Redis Database', EditDatabase = 'Edit Redis Database', @@ -77,12 +61,10 @@ const getInitialValues = (editedInstance?: Nullable>) => ({ : SshPassType.Password }) -const InstanceFormWrapper = (props: Props) => { +const ManualConnectionWrapper = (props: Props) => { const { editMode, width, - instanceType, - isResizablePanel = false, onClose, onDbEdited, onAliasEdited, @@ -96,7 +78,6 @@ const InstanceFormWrapper = (props: Props) => { const { host, port, name, username, password, timeout, tls, ssh, sshPassType, servername } = initialValues const { loadingChanging: loadingStandalone } = useSelector(instancesSelector) - const { loading: loadingSentinel } = useSelector(sentinelSelector) const { data: caCertificates } = useSelector(caCertsSelector) const { data: certificates } = useSelector(clientCertsSelector) const { server } = useSelector(appInfoSelector) @@ -153,40 +134,28 @@ const InstanceFormWrapper = (props: Props) => { return } - if (instanceType === InstanceType.Sentinel) { - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_SENTINEL_AUTODISCOVERY_SUBMITTED - }) - - delete payload.name - delete payload.db - dispatch(fetchMastersSentinelAction(payload, onMastersSentinelFetched)) - } else { - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_MANUALLY_SUBMITTED - }) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_MANUALLY_SUBMITTED + }) - if (urlHandlingAction === UrlHandlingActions.Connect) { - const cloudDetails = transformQueryParamsObject( - pick( - urlHandlingProperties, - ['cloudId', 'subscriptionType', 'planMemoryLimit', 'memoryLimitMeasurementUnit', 'free'] - ) + if (urlHandlingAction === UrlHandlingActions.Connect) { + const cloudDetails = transformQueryParamsObject( + pick( + urlHandlingProperties, + ['cloudId', 'subscriptionType', 'planMemoryLimit', 'memoryLimitMeasurementUnit', 'free'] ) + ) - const db = { ...payload } - if (cloudDetails?.cloudId) { - db.cloudDetails = cloudDetails - } - - dispatch(createInstanceStandaloneAction(db, undefined, handleSuccessConnectWithRedirect)) - return + const db = { ...payload } + if (cloudDetails?.cloudId) { + db.cloudDetails = cloudDetails } - dispatch( - createInstanceStandaloneAction(payload, onMastersSentinelFetched) - ) + dispatch(createInstanceStandaloneAction(db, undefined, handleSuccessConnectWithRedirect)) + return } + + dispatch(createInstanceStandaloneAction(payload, onMastersSentinelFetched)) } const handleEditDatabase = (payload: any) => { dispatch(updateInstanceAction(payload, onDbEdited)) @@ -261,8 +230,8 @@ const InstanceFormWrapper = (props: Props) => { clientCert: !tls ? undefined : typeof selectedTlsClientCertId === 'string' - && tlsClientAuthRequired - && selectedTlsClientCertId !== ADD_NEW + && tlsClientAuthRequired + && selectedTlsClientCertId !== ADD_NEW ? { id: selectedTlsClientCertId } : selectedTlsClientCertId === ADD_NEW && tlsClientAuthRequired ? { @@ -308,117 +277,6 @@ const InstanceFormWrapper = (props: Props) => { } } - const autoFillFormDetails = (content: string): boolean => { - try { - const details = new ConnectionString(content) - - /* If a protocol exists, it should be a redis protocol */ - if (details.protocol && !REDIS_URI_SCHEMES.includes(details.protocol)) return false - /* - * Auto fill logic: - * 1) If the port is parsed, we are sure that the user has indeed copied a connection string. - * '172.18.0.2:12000' => {host: '172,18.0.2', port: 12000} - * 'redis-12000.cluster.local:12000' => {host: 'redis-12000.cluster.local', port: 12000} - * 'lorem ipsum' => {host: undefined, port: undefined} - * 2) If the port is `undefined` but a redis URI scheme is present as protocol, we follow - * the "Scheme semantics" as mentioned in the official URI schemes. - * i) redis:// - https://www.iana.org/assignments/uri-schemes/prov/redis - * ii) rediss:// - https://www.iana.org/assignments/uri-schemes/prov/rediss - */ - if ( - details.port !== undefined - || REDIS_URI_SCHEMES.includes(details.protocol || '') - ) { - setInitialValues({ - name: details.host || name || 'localhost:6379', - host: details.hostname || host || 'localhost', - port: `${details.port || port || 9443}`, - username: details.user || '', - password: details.password, - tls: details.protocol === 'rediss', - ssh: false, - sshPassType: SshPassType.Password - } as any) - /* - * auto fill was successfull so return true - */ - return true - } - } catch (err) { - /* The pasted content is not a connection URI so ignore. */ - return false - } - return false - } - - const applyTlSDatabase = (database: any, tlsSettings: any) => { - const { useTls, verifyServerCert, servername, caCert, clientAuth, clientCert } = tlsSettings - if (!useTls) return - - database.tls = useTls - database.tlsServername = servername - database.verifyServerCert = !!verifyServerCert - - if (!isUndefined(caCert?.new)) { - database.caCert = { - name: caCert?.new.name, - certificate: caCert?.new.certificate, - } - } - - if (!isUndefined(caCert?.name)) { - database.caCert = { id: caCert?.name } - } - - if (clientAuth) { - if (!isUndefined(clientCert.new)) { - database.clientCert = { - name: clientCert.new.name, - certificate: clientCert.new.certificate, - key: clientCert.new.key, - } - } - - if (!isUndefined(clientCert.id)) { - database.clientCert = { id: clientCert.id } - } - } - } - - const applySSHDatabase = (database: any, values: DbConnectionInfo) => { - const { - ssh, - sshPassType, - sshHost, - sshPort, - sshPassword, - sshUsername, - sshPassphrase, - sshPrivateKey, - } = values - - if (ssh) { - database.ssh = true - database.sshOptions = { - host: sshHost, - port: +sshPort, - username: sshUsername, - } - - if (sshPassType === SshPassType.Password) { - database.sshOptions.password = sshPassword - database.sshOptions.passphrase = null - database.sshOptions.privateKey = null - } - - if (sshPassType === SshPassType.PrivateKey) { - database.sshOptions.password = null - database.sshOptions.passphrase = sshPassphrase - database.sshOptions.privateKey = sshPrivateKey - } - } - } - const editDatabase = (tlsSettings: any, values: DbConnectionInfo, isCloneMode: boolean) => { const { name, @@ -541,8 +399,8 @@ const InstanceFormWrapper = (props: Props) => { clientCert: !tls ? undefined : typeof selectedTlsClientCertId === 'string' - && tlsClientAuthRequired - && selectedTlsClientCertId !== ADD_NEW + && tlsClientAuthRequired + && selectedTlsClientCertId !== ADD_NEW ? { id: selectedTlsClientCertId } : selectedTlsClientCertId === ADD_NEW && tlsClientAuthRequired ? { @@ -600,9 +458,6 @@ const InstanceFormWrapper = (props: Props) => { } const getSubmitButtonText = () => { - if (instanceType === InstanceType.Sentinel) { - return SubmitBtnText.ConnectToSentinel - } if (isCloneMode) { return SubmitBtnText.CloneDatabase } @@ -612,21 +467,18 @@ const InstanceFormWrapper = (props: Props) => { return SubmitBtnText.AddDatabase } + const handlePostHostName = (content: string): boolean => ( + autoFillFormDetails(content, initialValues, setInitialValues, InstanceType.Standalone) + ) + return (
- { onSubmit={handleConnectionFormSubmit} onTestConnection={handleTestConnectionDatabase} onClose={handleOnClose} - onHostNamePaste={autoFillFormDetails} + onHostNamePaste={handlePostHostName} isEditMode={editMode} isCloneMode={isCloneMode} setIsCloneMode={setIsCloneMode} @@ -647,4 +499,4 @@ const InstanceFormWrapper = (props: Props) => { ) } -export default InstanceFormWrapper +export default ManualConnectionWrapper diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/index.ts b/redisinsight/ui/src/pages/home/components/ManualConnection/index.ts new file mode 100644 index 0000000000..28d4f3af8c --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/index.ts @@ -0,0 +1,3 @@ +import ManualConnectionWrapper from './ManualConnectionWrapper' + +export default ManualConnectionWrapper diff --git a/redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.spec.tsx b/redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.spec.tsx rename to redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.tsx b/redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.tsx similarity index 98% rename from redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.tsx rename to redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.tsx index dc7e73e4d0..a5703dcf5d 100644 --- a/redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.tsx +++ b/redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.tsx @@ -13,8 +13,7 @@ import LightActiveManualSvg from 'uiSrc/assets/img/light_theme/active_manual.svg import LightNotActiveManualSvg from 'uiSrc/assets/img/light_theme/n_active_manual.svg' import LightActiveAutoSvg from 'uiSrc/assets/img/light_theme/active_auto.svg' import LightNotActiveAutoSvg from 'uiSrc/assets/img/light_theme/n_active_auto.svg' - -import { AddDbType } from '../AddDatabasesContainer' +import { AddDbType } from 'uiSrc/pages/home/constants' import styles from '../styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.spec.tsx b/redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.spec.tsx similarity index 70% rename from redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.spec.tsx rename to redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.spec.tsx index 63013b6e49..238300f3b3 100644 --- a/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.spec.tsx @@ -1,19 +1,19 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import AddDatabasesContainer, { Props } from './AddDatabasesContainer' +import RightPanel, { Props } from './RightPanel' const mockedProps = mock() describe('AddDatabasesContainer', () => { it('should render', () => { expect( - render() + render() ).toBeTruthy() }) it('should render instance types after click on auto discover', () => { - render() + render() fireEvent.click(screen.getByTestId('add-auto')) expect(screen.getByTestId('db-types')).toBeInTheDocument() }) diff --git a/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx b/redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.tsx similarity index 82% rename from redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx rename to redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.tsx index 4d06fd40c1..305178786e 100644 --- a/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx +++ b/redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.tsx @@ -20,10 +20,12 @@ import { sentinelSelector, resetDataSentinel } from 'uiSrc/slices/instances/sent import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' +import { AddDbType } from 'uiSrc/pages/home/constants' +import ClusterConnectionFormWrapper from 'uiSrc/pages/home/components/ClusterConnection' +import CloudConnectionFormWrapper from 'uiSrc/pages/home/components/CloudConnection' +import SentinelConnectionWrapper from 'uiSrc/pages/home/components/SentinelConnection' +import ManualConnectionWrapper from 'uiSrc/pages/home/components/ManualConnection' import InstanceConnections from './InstanceConnections/InstanceConnections' -import InstanceFormWrapper from '../AddInstanceForm/InstanceFormWrapper' -import ClusterConnectionFormWrapper from '../ClusterConnection/ClusterConnectionFormWrapper' -import CloudConnectionFormWrapper from '../CloudConnection/CloudConnectionFormWrapper' import styles from './styles.module.scss' @@ -41,12 +43,7 @@ export interface Props { initConnectionType?: AddDbType } -export enum AddDbType { - manual, - auto, -} - -const AddDatabasesContainer = React.memo((props: Props) => { +const RightPanel = React.memo((props: Props) => { const { editMode, isResizablePanel, @@ -183,15 +180,12 @@ const AddDatabasesContainer = React.memo((props: Props) => { const Form = () => ( <> {connectionType === AddDbType.manual && ( - + )} {connectionType === AddDbType.auto && ( <> {typeSelected === InstanceType.Sentinel && ( - + )} {typeSelected === InstanceType.RedisEnterpriseCluster && ( @@ -208,29 +202,29 @@ const AddDatabasesContainer = React.memo((props: Props) => { <>
{!isFullWidth && onClose && ( - - - + + + )} {!editMode && ( - <> - -

Discover and Add Redis Databases

-
- - {connectionType === AddDbType.auto && } - + <> + +

Discover and Add Redis Databases

+
+ + {connectionType === AddDbType.auto && } + )} {Form()}
@@ -239,4 +233,4 @@ const AddDatabasesContainer = React.memo((props: Props) => { ) }) -export default AddDatabasesContainer +export default RightPanel diff --git a/redisinsight/ui/src/pages/home/components/RightPanel/index.ts b/redisinsight/ui/src/pages/home/components/RightPanel/index.ts new file mode 100644 index 0000000000..932f2fecad --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/RightPanel/index.ts @@ -0,0 +1,3 @@ +import RightPanel from './RightPanel' + +export default RightPanel diff --git a/redisinsight/ui/src/pages/home/components/AddDatabases/styles.module.scss b/redisinsight/ui/src/pages/home/components/RightPanel/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddDatabases/styles.module.scss rename to redisinsight/ui/src/pages/home/components/RightPanel/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx new file mode 100644 index 0000000000..0cf13ed72e --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx @@ -0,0 +1,200 @@ +import { + EuiButton, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + keys, +} from '@elastic/eui' +import { FormikErrors, useFormik } from 'formik' +import { isEmpty, pick } from 'lodash' +import React, { useRef, useState } from 'react' +import ReactDOM from 'react-dom' + +import validationErrors from 'uiSrc/constants/validationErrors' +import { useResizableFormField } from 'uiSrc/services' +import { ConnectionType, InstanceType } from 'uiSrc/slices/interfaces' + +import { + fieldDisplayNames, + SubmitBtnText, +} from 'uiSrc/pages/home/constants' +import { getFormErrors, getSubmitButtonContent } from 'uiSrc/pages/home/utils' +import { DbConnectionInfo, ISubmitButton } from 'uiSrc/pages/home/interfaces' +import { + MessageSentinel, + TlsDetails, + DatabaseForm, +} from 'uiSrc/pages/home/components/Form' + +export interface Props { + width: number + submitButtonText?: SubmitBtnText + loading: boolean + initialValues: DbConnectionInfo + certificates: { id: string; name: string }[], + caCertificates: { id: string; name: string }[], + onSubmit: (values: DbConnectionInfo) => void + onHostNamePaste: (content: string) => boolean + onClose?: () => void +} + +const getInitFieldsDisplayNames = ({ host, port }: any) => { + if (!host || !port) { + return pick(fieldDisplayNames, ['host', 'port']) + } + return {} +} + +const SentinelConnectionForm = (props: Props) => { + const { + initialValues, + width, + onClose, + onSubmit, + onHostNamePaste, + submitButtonText, + loading, + certificates, + caCertificates, + } = props + + const [errors, setErrors] = useState>( + getInitFieldsDisplayNames(initialValues) + ) + + const formRef = useRef(null) + + const submitIsDisable = () => !isEmpty(errors) + + const validate = (values: DbConnectionInfo) => { + const errs = getFormErrors(values) + setErrors(errs) + return errs + } + + const formik = useFormik({ + initialValues, + validate, + enableReinitialize: true, + onSubmit: (values: any) => { + onSubmit(values) + }, + }) + + const [flexGroupClassName, flexItemClassName] = useResizableFormField( + formRef, + width + ) + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === keys.ENTER && !submitIsDisable()) { + // event. + formik.submitForm() + } + } + + const SubmitButton = ({ + text = '', + onClick, + submitIsDisabled, + }: ISubmitButton) => ( + + + {text} + + + ) + + const Footer = () => { + const footerEl = document.getElementById('footerDatabaseForm') + + if (footerEl) { + return ReactDOM.createPortal( + + + + + {onClose && ( + + Cancel + + )} + + + + , + footerEl + ) + } + return null + } + + return ( +
+
+ +
+ + + + +
+
+
+ ) +} + +export default SentinelConnectionForm diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/index.ts new file mode 100644 index 0000000000..e598e90aae --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/index.ts @@ -0,0 +1,3 @@ +import SentinelConnectionForm from './SentinelConnectionForm' + +export default SentinelConnectionForm diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx new file mode 100644 index 0000000000..2c08e4f14b --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory } from 'react-router' + +import { fetchMastersSentinelAction, sentinelSelector, } from 'uiSrc/slices/instances/sentinel' +import { removeEmpty } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { caCertsSelector, fetchCaCerts } from 'uiSrc/slices/instances/caCerts' +import { Pages } from 'uiSrc/constants' +import { clientCertsSelector, fetchClientCerts, } from 'uiSrc/slices/instances/clientCerts' + +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' +import { applyTlSDatabase, autoFillFormDetails } from 'uiSrc/pages/home/utils' +import { ADD_NEW, ADD_NEW_CA_CERT, NO_CA_CERT, SubmitBtnText } from 'uiSrc/pages/home/constants' +import { InstanceType } from 'uiSrc/slices/interfaces' +import SentinelConnectionForm from './SentinelConnectionForm' + +export interface Props { + width: number + onClose?: () => void +} +const getDefaultHost = () => '127.0.0.1' +const getDefaultPort = () => '26379' + +const INITIAL_VALUES = { + host: getDefaultHost(), + port: getDefaultPort(), + username: '', + password: '', + tls: false, + tlsClientAuthRequired: false, + selectedTlsClientCertId: ADD_NEW, + verifyServerTlsCert: false, + selectedCaCertName: NO_CA_CERT, + +} + +const SentinelConnectionWrapper = (props: Props) => { + const { + width, + onClose, + } = props + const [initialValues, setInitialValues] = useState(INITIAL_VALUES) + + const { loading } = useSelector(sentinelSelector) + const { data: caCertificates } = useSelector(caCertsSelector) + const { data: certificates } = useSelector(clientCertsSelector) + + const history = useHistory() + const dispatch = useDispatch() + + useEffect(() => { + dispatch(fetchCaCerts()) + dispatch(fetchClientCerts()) + }, []) + + const onMastersSentinelFetched = () => { + history.push(Pages.sentinelDatabases) + } + + const handleSubmitDatabase = (payload: any) => { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_SENTINEL_AUTODISCOVERY_SUBMITTED + }) + + dispatch(fetchMastersSentinelAction(payload, onMastersSentinelFetched)) + } + + const addDatabase = (tlsSettings: any, values: DbConnectionInfo) => { + const { + host, + port, + username, + password, + } = values + const database: any = { + host, + port: +port, + username, + password, + } + + // add tls for database + applyTlSDatabase(database, tlsSettings) + handleSubmitDatabase(removeEmpty(database)) + } + + const handleConnectionFormSubmit = (values: DbConnectionInfo) => { + const { + newCaCert, + tls, + sni, + servername, + newCaCertName, + selectedCaCertName, + tlsClientAuthRequired, + verifyServerTlsCert, + newTlsCertPairName, + selectedTlsClientCertId, + newTlsClientCert, + newTlsClientKey, + } = values + + const tlsSettings = { + useTls: tls, + servername: (sni && servername) || undefined, + verifyServerCert: verifyServerTlsCert, + caCert: + !tls || selectedCaCertName === NO_CA_CERT + ? undefined + : selectedCaCertName === ADD_NEW_CA_CERT + ? { + new: { + name: newCaCertName, + certificate: newCaCert, + }, + } + : { + name: selectedCaCertName, + }, + clientAuth: tls && tlsClientAuthRequired, + clientCert: !tls + ? undefined + : typeof selectedTlsClientCertId === 'string' + && tlsClientAuthRequired + && selectedTlsClientCertId !== ADD_NEW + ? { id: selectedTlsClientCertId } + : selectedTlsClientCertId === ADD_NEW && tlsClientAuthRequired + ? { + new: { + name: newTlsCertPairName, + certificate: newTlsClientCert, + key: newTlsClientKey, + }, + } + : undefined, + } + + addDatabase(tlsSettings, values) + } + + const handlePostHostName = (content: string): boolean => ( + autoFillFormDetails(content, initialValues, setInitialValues, InstanceType.Sentinel) + ) + + return ( +
+ +
+ ) +} + +export default SentinelConnectionWrapper diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/index.ts b/redisinsight/ui/src/pages/home/components/SentinelConnection/index.ts new file mode 100644 index 0000000000..0947d0226b --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/index.ts @@ -0,0 +1,3 @@ +import SentinelConnectionWrapper from './SentinelConnectionWrapper' + +export default SentinelConnectionWrapper diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx index b5d92c3fbc..3f8301c8c8 100644 --- a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx @@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash' import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import { MOCKED_CREATE_REDIS_BTN_CONTENT } from 'uiSrc/mocks/content/content' -import { AddDbType } from 'uiSrc/pages/home/components/AddDatabases/AddDatabasesContainer' +import { AddDbType } from 'uiSrc/pages/home/constants' import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx index 3c05be266c..e5b9e275fe 100644 --- a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx +++ b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx @@ -10,14 +10,14 @@ import { ThemeContext } from 'uiSrc/contexts/themeContext' import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' import darkLogo from 'uiSrc/assets/img/dark_logo.svg' import lightLogo from 'uiSrc/assets/img/light_logo.svg' -import { AddDbType } from 'uiSrc/pages/home/components/AddDatabases/AddDatabasesContainer' import { ReactComponent as CloudStars } from 'uiSrc/assets/img/oauth/stars.svg' import { ReactComponent as CloudIcon } from 'uiSrc/assets/img/oauth/cloud.svg' import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import { getContentByFeature } from 'uiSrc/utils/content' -import { HELP_LINKS, IHelpGuide } from 'uiSrc/pages/home/constants/help-links' +import { AddDbType, HELP_LINKS, IHelpGuide } from 'uiSrc/pages/home/constants' + import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' import { FeatureFlagComponent, @@ -34,7 +34,7 @@ export interface Props { onAddInstance: (addDbType?: AddDbType) => void } -const Welcome = ({ onAddInstance }: Props) => { +const WelcomeComponent = ({ onAddInstance }: Props) => { const featureFlags = useSelector(appFeatureFlagsFeaturesSelector) const { loading, data } = useSelector(contentSelector) @@ -293,4 +293,4 @@ const Welcome = ({ onAddInstance }: Props) => { ) } -export default Welcome +export default WelcomeComponent diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/index.ts b/redisinsight/ui/src/pages/home/components/WelcomeComponent/index.ts new file mode 100644 index 0000000000..b198b75e9b --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/WelcomeComponent/index.ts @@ -0,0 +1,3 @@ +import WelcomeComponent from './WelcomeComponent' + +export default WelcomeComponent diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss b/redisinsight/ui/src/pages/home/components/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss rename to redisinsight/ui/src/pages/home/components/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/constants/database.ts b/redisinsight/ui/src/pages/home/constants/database.ts new file mode 100644 index 0000000000..edd8e9585c --- /dev/null +++ b/redisinsight/ui/src/pages/home/constants/database.ts @@ -0,0 +1,4 @@ +export enum AddDbType { + manual, + auto, +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts b/redisinsight/ui/src/pages/home/constants/form.ts similarity index 83% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts rename to redisinsight/ui/src/pages/home/constants/form.ts index 057ce40220..ca72f717f8 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts +++ b/redisinsight/ui/src/pages/home/constants/form.ts @@ -29,3 +29,10 @@ export const fieldDisplayNames = { const DEFAULT_TIMEOUT_ENV = process.env.CONNECTIONS_TIMEOUT_DEFAULT || '30000' // 30 sec export const DEFAULT_TIMEOUT = parseInt(DEFAULT_TIMEOUT_ENV, 10) + +export enum SubmitBtnText { + AddDatabase = 'Add Redis Database', + EditDatabase = 'Apply changes', + ConnectToSentinel = 'Discover database', + CloneDatabase = 'Clone Database' +} diff --git a/redisinsight/ui/src/pages/home/constants/index.ts b/redisinsight/ui/src/pages/home/constants/index.ts new file mode 100644 index 0000000000..59ec3920ec --- /dev/null +++ b/redisinsight/ui/src/pages/home/constants/index.ts @@ -0,0 +1,3 @@ +export * from './form' +export * from './help-links' +export * from './database' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts b/redisinsight/ui/src/pages/home/interfaces/form.ts similarity index 89% rename from redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts rename to redisinsight/ui/src/pages/home/interfaces/form.ts index a52a19322a..2f372161bb 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts +++ b/redisinsight/ui/src/pages/home/interfaces/form.ts @@ -1,7 +1,8 @@ import { Instance } from 'uiSrc/slices/interfaces' -import { ADD_NEW_CA_CERT, NO_CA_CERT } from './constants' +import { ADD_NEW_CA_CERT, NO_CA_CERT } from 'uiSrc/pages/home/constants' export interface DbConnectionInfo extends Instance { + id?: string port: string tlsClientAuthRequired?: boolean certificates?: { id: number; name: string }[] @@ -26,8 +27,8 @@ export interface DbConnectionInfo extends Instance { sentinelMasterName?: string ssh?: boolean sshPassType?: string - sshHost: string - sshPort: string + sshHost?: string + sshPort?: string sshUsername?: string sshPassword?: string | true sshPrivateKey?: string | true diff --git a/redisinsight/ui/src/pages/home/interfaces/index.ts b/redisinsight/ui/src/pages/home/interfaces/index.ts new file mode 100644 index 0000000000..54151771d5 --- /dev/null +++ b/redisinsight/ui/src/pages/home/interfaces/index.ts @@ -0,0 +1 @@ +export * from './form' diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx new file mode 100644 index 0000000000..bd02e85369 --- /dev/null +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -0,0 +1,229 @@ +import { ConnectionString } from 'connection-string' +import { isUndefined } from 'lodash' +import React from 'react' +import { FormikErrors } from 'formik' +import { REDIS_URI_SCHEMES } from 'uiSrc/constants' +import { InstanceType } from 'uiSrc/slices/interfaces' +import {ADD_NEW, ADD_NEW_CA_CERT, fieldDisplayNames, NO_CA_CERT, SshPassType} from 'uiSrc/pages/home/constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' + +export const applyTlSDatabase = (database: any, tlsSettings: any) => { + const { useTls, verifyServerCert, servername, caCert, clientAuth, clientCert } = tlsSettings + if (!useTls) return + + database.tls = useTls + database.tlsServername = servername + database.verifyServerCert = !!verifyServerCert + + if (!isUndefined(caCert?.new)) { + database.caCert = { + name: caCert?.new.name, + certificate: caCert?.new.certificate, + } + } + + if (!isUndefined(caCert?.name)) { + database.caCert = { id: caCert?.name } + } + + if (clientAuth) { + if (!isUndefined(clientCert.new)) { + database.clientCert = { + name: clientCert.new.name, + certificate: clientCert.new.certificate, + key: clientCert.new.key, + } + } + + if (!isUndefined(clientCert.id)) { + database.clientCert = { id: clientCert.id } + } + } +} + +export const applySSHDatabase = (database: any, values: DbConnectionInfo) => { + const { + ssh, + sshPassType, + sshHost, + sshPort, + sshPassword, + sshUsername, + sshPassphrase, + sshPrivateKey, + } = values + + if (ssh) { + database.ssh = true + database.sshOptions = { + host: sshHost, + port: +sshPort, + username: sshUsername, + } + + if (sshPassType === SshPassType.Password) { + database.sshOptions.password = sshPassword + database.sshOptions.passphrase = null + database.sshOptions.privateKey = null + } + + if (sshPassType === SshPassType.PrivateKey) { + database.sshOptions.password = null + database.sshOptions.passphrase = sshPassphrase + database.sshOptions.privateKey = sshPrivateKey + } + } +} + +export const getFormErrors = (values: DbConnectionInfo) => { + const errs: FormikErrors = {} + + if (!values.host) { + errs.host = fieldDisplayNames.host + } + if (!values.port) { + errs.port = fieldDisplayNames.port + } + + if ( + values.tls + && values.verifyServerTlsCert + && values.selectedCaCertName === NO_CA_CERT + ) { + errs.selectedCaCertName = fieldDisplayNames.selectedCaCertName + } + + if ( + values.tls + && values.selectedCaCertName === ADD_NEW_CA_CERT + && values.newCaCertName === '' + ) { + errs.newCaCertName = fieldDisplayNames.newCaCertName + } + + if ( + values.tls + && values.selectedCaCertName === ADD_NEW_CA_CERT + && values.newCaCert === '' + ) { + errs.newCaCert = fieldDisplayNames.newCaCert + } + + if ( + values.tls + && values.sni + && values.servername === '' + ) { + errs.servername = fieldDisplayNames.servername + } + + if ( + values.tls + && values.tlsClientAuthRequired + && values.selectedTlsClientCertId === ADD_NEW + ) { + if (values.newTlsCertPairName === '') { + errs.newTlsCertPairName = fieldDisplayNames.newTlsCertPairName + } + if (values.newTlsClientCert === '') { + errs.newTlsClientCert = fieldDisplayNames.newTlsClientCert + } + if (values.newTlsClientKey === '') { + errs.newTlsClientKey = fieldDisplayNames.newTlsClientKey + } + } + + return errs +} + +export const autoFillFormDetails = ( + content: string, + initialValues: any, + setInitialValues: (data: any) => void, + instanceType: InstanceType +): boolean => { + try { + const details = new ConnectionString(content) + + /* If a protocol exists, it should be a redis protocol */ + if (details.protocol && !REDIS_URI_SCHEMES.includes(details.protocol)) return false + /* + * Auto fill logic: + * 1) If the port is parsed, we are sure that the user has indeed copied a connection string. + * '172.18.0.2:12000' => {host: '172,18.0.2', port: 12000} + * 'redis-12000.cluster.local:12000' => {host: 'redis-12000.cluster.local', port: 12000} + * 'lorem ipsum' => {host: undefined, port: undefined} + * 2) If the port is `undefined` but a redis URI scheme is present as protocol, we follow + * the "Scheme semantics" as mentioned in the official URI schemes. + * i) redis:// - https://www.iana.org/assignments/uri-schemes/prov/redis + * ii) rediss:// - https://www.iana.org/assignments/uri-schemes/prov/rediss + */ + if ( + details.port !== undefined + || REDIS_URI_SCHEMES.includes(details.protocol || '') + ) { + const getUpdatedInitialValues = () => { + switch (instanceType) { + case InstanceType.RedisEnterpriseCluster: { + return ({ + host: details.hostname || initialValues.host || 'localhost', + port: `${details.port || initialValues.port || 9443}`, + username: details.user || '', + password: details.password || '', + }) + } + + case InstanceType.Sentinel: { + return ({ + host: details.hostname || initialValues.host || 'localhost', + port: `${details.port || initialValues.port || 9443}`, + username: details.user || '', + password: details.password, + tls: details.protocol === 'rediss', + }) + } + + case InstanceType.Standalone: { + return ({ + name: details.host || initialValues.name || 'localhost:6379', + host: details.hostname || initialValues.host || 'localhost', + port: `${details.port || initialValues.port || 9443}`, + username: details.user || '', + password: details.password, + tls: details.protocol === 'rediss', + ssh: false, + sshPassType: SshPassType.Password + }) + } + default: { + return {} + } + } + } + setInitialValues(getUpdatedInitialValues()) + /* + * autofill was successfull so return true + */ + return true + } + } catch (err) { + /* The pasted content is not a connection URI so ignore. */ + return false + } + return false +} + +export const getSubmitButtonContent = (errors: FormikErrors, submitIsDisabled?: boolean) => { + const maxErrorsCount = 5 + const errorsArr = Object.values(errors).map((err) => [ + err, +
, + ]) + + if (errorsArr.length > maxErrorsCount) { + errorsArr.splice(maxErrorsCount, errorsArr.length, ['...']) + } + return submitIsDisabled ? ( + {errorsArr} + ) : null +} diff --git a/redisinsight/ui/src/pages/home/utils/index.ts b/redisinsight/ui/src/pages/home/utils/index.ts new file mode 100644 index 0000000000..54151771d5 --- /dev/null +++ b/redisinsight/ui/src/pages/home/utils/index.ts @@ -0,0 +1 @@ +export * from './form' diff --git a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx index dea2c66625..874e8cf71f 100644 --- a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx +++ b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx @@ -11,17 +11,17 @@ import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { setConnectedInstanceId } from 'uiSrc/slices/instances/instances' import { appInfoSelector } from 'uiSrc/slices/app/info' import { Instance } from 'uiSrc/slices/interfaces' -import AddDatabaseContainer from 'uiSrc/pages/home/components/AddDatabases/AddDatabasesContainer' import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' import PromoLink from 'uiSrc/components/promo-link/PromoLink' import { getPathToResource } from 'uiSrc/services/resourcesService' -import { HELP_LINKS } from 'uiSrc/pages/home/constants/help-links' +import { HELP_LINKS } from 'uiSrc/pages/home/constants' import { sendEventTelemetry } from 'uiSrc/telemetry' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import './styles.scss' import styles from './styles.module.scss' +import RightPanel from 'uiSrc/pages/home/components/RightPanel/RightPanel' interface IState { loading: boolean; @@ -119,7 +119,7 @@ const EditConnection = () => { )}
- { const maxErrorsCount = 5 const errorsArr = Object.values(errors).map((err) => [ err, -
, +
, ]) if (errorsArr.length > maxErrorsCount) { diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index 81590223fb..775ca7f200 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -471,7 +471,7 @@ function autoCreateAndConnectToInstanceActionSuccess( } // Asynchronous thunk action -export function updateInstanceAction({ id, ...payload }: Instance, onSuccess?: () => void) { +export function updateInstanceAction({ id, ...payload }: Partial, onSuccess?: () => void) { return async (dispatch: AppDispatch) => { dispatch(defaultInstanceChanging()) diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 5df4bb4edf..39f09e333f 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -15,7 +15,7 @@ import { CreateSentinelDatabaseDto } from 'apiSrc/modules/redis-sentinel/dto/cre import { CreateSentinelDatabaseResponse } from 'apiSrc/modules/redis-sentinel/dto/create.sentinel.database.response' import { RedisNodeInfoResponse } from 'apiSrc/modules/database/dto/redis-info.dto' -export interface Instance extends DatabaseInstanceResponse { +export interface Instance extends Partial { host: string port: number nameFromProvider?: Nullable From 9aff8d97796291cc2a8dcfa905507493a64a1a6c Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 1 Nov 2023 10:59:29 +0400 Subject: [PATCH 14/96] #RI-5009 - remove deprecated code --- .../ManualConnectionForm.tsx | 345 +++++++++--------- .../ManualConnectionWrapper.tsx | 126 +------ .../SentinelConnectionWrapper.tsx | 62 +--- redisinsight/ui/src/pages/home/utils/form.tsx | 37 +- .../edit-connection/EditConnection.tsx | 2 +- 5 files changed, 220 insertions(+), 352 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx index 5582d422ac..42f081107f 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx @@ -39,6 +39,7 @@ import { SshPassType, DEFAULT_TIMEOUT, NONE, + SubmitBtnText, } from 'uiSrc/pages/home/constants' import { getFormErrors, getSubmitButtonContent } from 'uiSrc/pages/home/utils' import { DbConnectionInfo, ISubmitButton } from 'uiSrc/pages/home/interfaces' @@ -57,7 +58,6 @@ import { SentinelHostPort, SentinelMasterDatabase, } from 'uiSrc/pages/home/components/Form/sentinel' -import { SubmitBtnText } from 'uiSrc/pages/home/components/ManualConnection/ManualConnectionWrapper' export interface Props { width: number @@ -418,68 +418,12 @@ const ManualConnectionForm = (props: Props) => { )}
{!isEditMode && !isFromCloud && ( - <> - -
- + <> + +
+ )} {!isEditMode && !isFromCloud && ( - - - - - - {buildType !== BuildType.RedisStack && ( - - )} - - )} - {(isEditMode || isCloneMode || isFromCloud) && connectionType !== ConnectionType.Sentinel && ( - <> - {!isCloneMode && ( - - )} { > - {isCloneMode && ( - )} { caCertificates={caCertificates} /> {buildType !== BuildType.RedisStack && ( - + )} - )} - {(isEditMode || isCloneMode) && connectionType === ConnectionType.Sentinel && ( - <> - + {(isEditMode || isCloneMode || isFromCloud) && connectionType !== ConnectionType.Sentinel && ( + <> {!isCloneMode && ( - <> - - - - - - - - - - - - - )} - {isCloneMode && ( - <> - + - - - - - - - - + )} { certificates={certificates} caCertificates={caCertificates} /> - - )} - - + {buildType !== BuildType.RedisStack && ( + + )} + + + )} + {(isEditMode || isCloneMode) && connectionType === ConnectionType.Sentinel && ( + <> + + {!isCloneMode && ( + <> + + + + + + + + + + + + + )} + {isCloneMode && ( + <> + + + + + + + + + + + + + )} + + )}
diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx index 429eff83c6..e36fb9b484 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx @@ -23,8 +23,8 @@ import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { getRedirectionPage } from 'uiSrc/utils/routing' import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' -import { applyTlSDatabase, applySSHDatabase, autoFillFormDetails } from 'uiSrc/pages/home/utils' -import { ADD_NEW, ADD_NEW_CA_CERT, DEFAULT_TIMEOUT, NO_CA_CERT, SshPassType, SubmitBtnText } from 'uiSrc/pages/home/constants' +import { applyTlSDatabase, applySSHDatabase, autoFillFormDetails, getTlsSettings } from 'uiSrc/pages/home/utils' +import { ADD_NEW, DEFAULT_TIMEOUT, NO_CA_CERT, SshPassType, SubmitBtnText } from 'uiSrc/pages/home/constants' import ManualConnectionForm from './ManualConnectionForm' export interface Props { @@ -38,11 +38,6 @@ export interface Props { onAliasEdited?: (value: string) => void } -export enum TitleDatabaseText { - AddDatabase = 'Add Redis Database', - EditDatabase = 'Edit Redis Database', -} - const getInitialValues = (editedInstance?: Nullable>) => ({ // undefined - to show default value, empty string - for existing db host: editedInstance?.host ?? (editedInstance ? '' : undefined), @@ -165,20 +160,6 @@ const ManualConnectionWrapper = (props: Props) => { dispatch(cloneInstanceAction(payload)) } - const handleUpdateEditingName = (name: string) => { - const requiredFields = [ - 'id', - 'host', - 'port', - 'username', - 'password', - 'tls', - 'sentinelMaster', - ] - const database = pick(editedInstance, ...requiredFields) - dispatch(updateInstanceAction({ ...database, name })) - } - const handleTestConnectionDatabase = (values: DbConnectionInfo) => { sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_TEST_CONNECTION_CLICKED @@ -195,54 +176,9 @@ const ManualConnectionWrapper = (props: Props) => { sentinelMasterName, sentinelMasterUsername, sentinelMasterPassword, - newCaCert, - tls, - sni, - servername, - newCaCertName, - selectedCaCertName, - tlsClientAuthRequired, - verifyServerTlsCert, - newTlsCertPairName, - selectedTlsClientCertId, - newTlsClientCert, - newTlsClientKey, } = values - const tlsSettings = { - useTls: tls, - servername: (sni && servername) || undefined, - verifyServerCert: verifyServerTlsCert, - caCert: - !tls || selectedCaCertName === NO_CA_CERT - ? undefined - : selectedCaCertName === ADD_NEW_CA_CERT - ? { - new: { - name: newCaCertName, - certificate: newCaCert, - }, - } - : { - name: selectedCaCertName, - }, - clientAuth: tls && tlsClientAuthRequired, - clientCert: !tls - ? undefined - : typeof selectedTlsClientCertId === 'string' - && tlsClientAuthRequired - && selectedTlsClientCertId !== ADD_NEW - ? { id: selectedTlsClientCertId } - : selectedTlsClientCertId === ADD_NEW && tlsClientAuthRequired - ? { - new: { - name: newTlsCertPairName, - certificate: newTlsClientCert, - key: newTlsClientKey, - }, - } - : undefined, - } + const tlsSettings = getTlsSettings(values) const database: any = { name, @@ -363,55 +299,7 @@ const ManualConnectionWrapper = (props: Props) => { } const handleConnectionFormSubmit = (values: DbConnectionInfo) => { - const { - newCaCert, - tls, - sni, - servername, - newCaCertName, - selectedCaCertName, - tlsClientAuthRequired, - verifyServerTlsCert, - newTlsCertPairName, - selectedTlsClientCertId, - newTlsClientCert, - newTlsClientKey, - } = values - - const tlsSettings = { - useTls: tls, - servername: (sni && servername) || undefined, - verifyServerCert: verifyServerTlsCert, - caCert: - !tls || selectedCaCertName === NO_CA_CERT - ? undefined - : selectedCaCertName === ADD_NEW_CA_CERT - ? { - new: { - name: newCaCertName, - certificate: newCaCert, - }, - } - : { - name: selectedCaCertName, - }, - clientAuth: tls && tlsClientAuthRequired, - clientCert: !tls - ? undefined - : typeof selectedTlsClientCertId === 'string' - && tlsClientAuthRequired - && selectedTlsClientCertId !== ADD_NEW - ? { id: selectedTlsClientCertId } - : selectedTlsClientCertId === ADD_NEW && tlsClientAuthRequired - ? { - new: { - name: newTlsCertPairName, - certificate: newTlsClientCert, - key: newTlsClientKey, - }, - } - : undefined, - } + const tlsSettings = getTlsSettings(values) if (editMode) { editDatabase(tlsSettings, values, isCloneMode) @@ -480,11 +368,6 @@ const ManualConnectionWrapper = (props: Props) => { loading={loadingStandalone} buildType={server?.buildType as BuildType} submitButtonText={getSubmitButtonText()} - titleText={ - editMode - ? TitleDatabaseText.EditDatabase - : TitleDatabaseText.AddDatabase - } onSubmit={handleConnectionFormSubmit} onTestConnection={handleTestConnectionDatabase} onClose={handleOnClose} @@ -492,7 +375,6 @@ const ManualConnectionWrapper = (props: Props) => { isEditMode={editMode} isCloneMode={isCloneMode} setIsCloneMode={setIsCloneMode} - updateEditingName={handleUpdateEditingName} onAliasEdited={onAliasEdited} />
diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx index 2c08e4f14b..d190ccb76f 100644 --- a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx @@ -10,8 +10,8 @@ import { Pages } from 'uiSrc/constants' import { clientCertsSelector, fetchClientCerts, } from 'uiSrc/slices/instances/clientCerts' import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' -import { applyTlSDatabase, autoFillFormDetails } from 'uiSrc/pages/home/utils' -import { ADD_NEW, ADD_NEW_CA_CERT, NO_CA_CERT, SubmitBtnText } from 'uiSrc/pages/home/constants' +import { applyTlSDatabase, autoFillFormDetails, getTlsSettings } from 'uiSrc/pages/home/utils' +import { ADD_NEW, NO_CA_CERT, SubmitBtnText } from 'uiSrc/pages/home/constants' import { InstanceType } from 'uiSrc/slices/interfaces' import SentinelConnectionForm from './SentinelConnectionForm' @@ -19,12 +19,12 @@ export interface Props { width: number onClose?: () => void } -const getDefaultHost = () => '127.0.0.1' -const getDefaultPort = () => '26379' +const DEFAULT_SENTINEL_HOST = '127.0.0.1' +const DEFAULT_SENTINEL_PORT = '26379' const INITIAL_VALUES = { - host: getDefaultHost(), - port: getDefaultPort(), + host: DEFAULT_SENTINEL_HOST, + port: DEFAULT_SENTINEL_PORT, username: '', password: '', tls: false, @@ -86,55 +86,7 @@ const SentinelConnectionWrapper = (props: Props) => { } const handleConnectionFormSubmit = (values: DbConnectionInfo) => { - const { - newCaCert, - tls, - sni, - servername, - newCaCertName, - selectedCaCertName, - tlsClientAuthRequired, - verifyServerTlsCert, - newTlsCertPairName, - selectedTlsClientCertId, - newTlsClientCert, - newTlsClientKey, - } = values - - const tlsSettings = { - useTls: tls, - servername: (sni && servername) || undefined, - verifyServerCert: verifyServerTlsCert, - caCert: - !tls || selectedCaCertName === NO_CA_CERT - ? undefined - : selectedCaCertName === ADD_NEW_CA_CERT - ? { - new: { - name: newCaCertName, - certificate: newCaCert, - }, - } - : { - name: selectedCaCertName, - }, - clientAuth: tls && tlsClientAuthRequired, - clientCert: !tls - ? undefined - : typeof selectedTlsClientCertId === 'string' - && tlsClientAuthRequired - && selectedTlsClientCertId !== ADD_NEW - ? { id: selectedTlsClientCertId } - : selectedTlsClientCertId === ADD_NEW && tlsClientAuthRequired - ? { - new: { - name: newTlsCertPairName, - certificate: newTlsClientCert, - key: newTlsClientKey, - }, - } - : undefined, - } + const tlsSettings = getTlsSettings(values) addDatabase(tlsSettings, values) } diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx index bd02e85369..eda2a1f610 100644 --- a/redisinsight/ui/src/pages/home/utils/form.tsx +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -4,9 +4,44 @@ import React from 'react' import { FormikErrors } from 'formik' import { REDIS_URI_SCHEMES } from 'uiSrc/constants' import { InstanceType } from 'uiSrc/slices/interfaces' -import {ADD_NEW, ADD_NEW_CA_CERT, fieldDisplayNames, NO_CA_CERT, SshPassType} from 'uiSrc/pages/home/constants' +import { ADD_NEW, ADD_NEW_CA_CERT, fieldDisplayNames, NO_CA_CERT, SshPassType } from 'uiSrc/pages/home/constants' import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' +export const getTlsSettings = (values: DbConnectionInfo) => ({ + useTls: values.tls, + servername: (values.sni && values.servername) || undefined, + verifyServerCert: values.verifyServerTlsCert, + caCert: + !values.tls || values.selectedCaCertName === NO_CA_CERT + ? undefined + : values.selectedCaCertName === ADD_NEW_CA_CERT + ? { + new: { + name: values.newCaCertName, + certificate: values.newCaCert, + }, + } + : { + name: values.selectedCaCertName, + }, + clientAuth: values.tls && values.tlsClientAuthRequired, + clientCert: !values.tls + ? undefined + : typeof values.selectedTlsClientCertId === 'string' + && values.tlsClientAuthRequired + && values.selectedTlsClientCertId !== ADD_NEW + ? { id: values.selectedTlsClientCertId } + : values.selectedTlsClientCertId === ADD_NEW && values.tlsClientAuthRequired + ? { + new: { + name: values.newTlsCertPairName, + certificate: values.newTlsClientCert, + key: values.newTlsClientKey, + }, + } + : undefined, +}) + export const applyTlSDatabase = (database: any, tlsSettings: any) => { const { useTls, verifyServerCert, servername, caCert, clientAuth, clientCert } = tlsSettings if (!useTls) return diff --git a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx index 874e8cf71f..c167d4b33a 100644 --- a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx +++ b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx @@ -18,10 +18,10 @@ import { HELP_LINKS } from 'uiSrc/pages/home/constants' import { sendEventTelemetry } from 'uiSrc/telemetry' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' +import RightPanel from 'uiSrc/pages/home/components/RightPanel/RightPanel' import './styles.scss' import styles from './styles.module.scss' -import RightPanel from 'uiSrc/pages/home/components/RightPanel/RightPanel' interface IState { loading: boolean; From 139fb5bca84dd0e32bacd93b9a0b717879e80b0d Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 1 Nov 2023 11:47:49 +0400 Subject: [PATCH 15/96] add tests --- .../ManualConnectionForm.tsx | 9 + .../ManualConnectionFrom.spec.tsx | 1259 +++++++++++++++++ .../SentinelConnectionForm.spec.tsx | 14 + .../SentinelConnectionForm.tsx | 2 +- redisinsight/ui/src/pages/home/utils/form.tsx | 15 + 5 files changed, 1298 insertions(+), 1 deletion(-) create mode 100644 redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx index 42f081107f..8854d5a8f5 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx @@ -197,6 +197,15 @@ const ManualConnectionForm = (props: Props) => { const validate = (values: DbConnectionInfo) => { const errs = getFormErrors(values) + + if (isCloneMode && connectionType === ConnectionType.Sentinel && !values.sentinelMasterName) { + errs.sentinelMasterName = fieldDisplayNames.sentinelMasterName + } + + if (!values.name) { + errs.name = fieldDisplayNames.name + } + setErrors(errs) return errs } diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx new file mode 100644 index 0000000000..88607e7c09 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx @@ -0,0 +1,1259 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { BuildType } from 'uiSrc/constants/env' +import { appRedirectionSelector } from 'uiSrc/slices/app/url-handling' +import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' +import { ADD_NEW_CA_CERT, SshPassType } from 'uiSrc/pages/home/constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' + +import ManualConnectionForm, { Props } from './ManualConnectionForm' + +const BTN_SUBMIT = 'btn-submit' +const NEW_CA_CERT = 'new-ca-cert' +const QA_CA_CERT = 'qa-ca-cert' +const RADIO_BTN_PRIVATE_KEY = '[data-test-subj="radio-btn-privateKey"] label' +const BTN_TEST_CONNECTION = 'btn-test-connection' + +const mockedProps = mock() +const mockedDbConnectionInfo = mock() + +const formFields = { + ...instance(mockedDbConnectionInfo), + host: 'localhost', + port: '6379', + name: 'lala', + caCertificates: [], + certificates: [], +} + +jest.mock('uiSrc/slices/instances/instances', () => ({ + checkConnectToInstanceAction: () => jest.fn, + resetInstanceUpdateAction: () => jest.fn, + changeInstanceAliasAction: () => jest.fn, + setConnectedInstanceId: jest.fn, +})) + +jest.mock('uiSrc/slices/app/url-handling', () => ({ + ...jest.requireActual('uiSrc/slices/app/url-handling'), + appRedirectionSelector: jest.fn().mockReturnValue(() => ({ action: null })), +})) + +describe('InstanceForm', () => { + it('should render', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should render with ConnectionType.Sentinel', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should render with ConnectionType.Cluster', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should render tooltip with nodes', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should render DatabaseForm', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should change sentinelMasterUsername input properly', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + + render( +
+ +
+ ) + + await act(() => { + fireEvent.change(screen.getByTestId('sentinel-mater-username'), { + target: { value: 'user' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + sentinelMasterUsername: 'user', + }) + ) + + await act(() => { + fireEvent.click(submitBtn) + }) + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sentinelMasterUsername: 'user', + }) + ) + }) + + it('should change port input properly', async () => { + const handleSubmit = jest.fn() + render( +
+ +
+ ) + + await act(() => { + fireEvent.change(screen.getByTestId('port'), { + target: { value: '123' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + await act(() => { + fireEvent.click(submitBtn) + }) + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + port: '123', + }) + ) + }) + + it('should change tls checkbox', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('tls')) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + tls: ['on'], + }) + ) + + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + tls: ['on'], + }) + ) + }) + + it('should change Database Index checkbox', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('showDb')) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + showDb: true, + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + showDb: true, + }) + ) + }) + + it('should change db checkbox and value', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('showDb')) + }) + + await act(() => { + fireEvent.change(screen.getByTestId('db'), { + target: { value: '12' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + showDb: true, + db: '12' + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + showDb: true, + db: '12' + }) + ) + }) + + it('should change "Use SNI" with prepopulated with host', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('sni')) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + sni: true, + servername: formFields.host + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sni: true, + servername: formFields.host + }) + ) + }) + + it('should change "Use SNI"', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('sni')) + }) + + await act(() => { + fireEvent.change(screen.getByTestId('sni-servername'), { + target: { value: '12' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + sni: true, + servername: '12' + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sni: true, + servername: '12' + }) + ) + }) + + it('should change "Verify TLS Certificate"', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('verify-tls-cert')) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + verifyServerTlsCert: ['on'], + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + verifyServerTlsCert: ['on'], + }) + ) + }) + + it('should select value from "CA Certificate"', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + const { queryByText } = render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('select-ca-cert')) + }) + await act(() => { + fireEvent.click(queryByText('Add new CA certificate') || document) + }) + + expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId(NEW_CA_CERT), { + target: { value: '123' }, + }) + }) + + expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId(QA_CA_CERT), { + target: { value: '321' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + selectedCaCertName: ADD_NEW_CA_CERT, + newCaCertName: '321', + newCaCert: '123', + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + selectedCaCertName: ADD_NEW_CA_CERT, + newCaCertName: '321', + newCaCert: '123', + }) + ) + }) + + it('should render fields for add new CA and change them properly', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + + expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId(QA_CA_CERT), { + target: { value: '321' }, + }) + }) + + expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId(NEW_CA_CERT), { + target: { value: '123' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + newCaCert: '123', + newCaCertName: '321', + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + newCaCert: '123', + newCaCertName: '321', + }) + ) + }) + + it('should change "Requires TLS Client Authentication"', async () => { + const handleSubmit = jest.fn() + const handleTestConnection = jest.fn() + render( +
+ +
+ ) + await act(() => { + fireEvent.click(screen.getByTestId('tls-required-checkbox')) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + await act(() => { + fireEvent.click(testConnectionBtn) + }) + expect(handleTestConnection).toBeCalledWith( + expect.objectContaining({ + tlsClientAuthRequired: ['on'], + }) + ) + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + tlsClientAuthRequired: ['on'], + }) + ) + }) + + it('should render fields for add new CA with required tls auth and change them properly', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
+ +
+ ) + + expect(screen.getByTestId('select-cert')).toBeInTheDocument() + + await act(() => { + fireEvent.click(screen.getByTestId('select-cert')) + }) + + await act(() => { + fireEvent.click( + container.querySelectorAll('.euiContextMenuItem__text')[0] || document + ) + }) + + expect(screen.getByTestId('new-tsl-cert-pair-name')).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId('new-tsl-cert-pair-name'), { + target: { value: '123' }, + }) + }) + + expect(screen.getByTestId('new-tls-client-cert')).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId('new-tls-client-cert'), { + target: { value: '321' }, + }) + }) + + expect(screen.getByTestId('new-tls-client-cert-key')).toBeInTheDocument() + await act(() => { + fireEvent.change(screen.getByTestId('new-tls-client-cert-key'), { + target: { value: '231' }, + }) + }) + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + + await act(() => { + fireEvent.click(submitBtn) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + newTlsClientCert: '321', + newTlsCertPairName: '123', + newTlsClientKey: '231', + }) + ) + }) + + it('should render clone mode btn', () => { + render( + + ) + expect(screen.getByTestId('clone-db-btn')).toBeTruthy() + }) + + describe('should render proper fields with Clone mode', () => { + it('should render proper fields for standalone db', () => { + render( + + ) + const fieldsTestIds = ['host', 'port', 'username', 'password', 'showDb', 'tls'] + fieldsTestIds.forEach((id) => { + expect(screen.getByTestId(id)).toBeTruthy() + }) + }) + + it('should render proper fields for sentinel db', () => { + render( + + ) + const fieldsTestIds = [ + 'name', + 'primary-group', + 'sentinel-mater-username', + 'sentinel-master-password', + 'host', + 'port', + 'username', + 'password', + 'showDb', + 'tls' + ] + fieldsTestIds.forEach((id) => { + expect(screen.getByTestId(id)).toBeTruthy() + }) + }) + + it('should render selected logical database with proper db index', () => { + render( + + ) + expect(screen.getByTestId('showDb')).toBeChecked() + expect(screen.getByTestId('db')).toHaveValue('5') + }) + + it('should render proper database alias', () => { + render( + + ) + expect(screen.getByTestId('db-alias')).toHaveTextContent('Clone ') + }) + + it('should render proper default values for standalone', () => { + render( + + ) + expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') + expect(screen.getByTestId('port')).toHaveValue('6379') + expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') + }) + + // it('should render proper default values for sentinel', () => { + // render( + // + // ) + // expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') + // expect(screen.getByTestId('port')).toHaveValue('26379') + // }) + }) + + it('should change Use SSH checkbox', async () => { + const handleSubmit = jest.fn() + render( +
+ +
+ ) + + fireEvent.click(screen.getByTestId('use-ssh')) + + expect(screen.getByTestId('use-ssh')).toBeChecked() + }) + + it('should not render Use SSH checkbox for redis stack buidlType', async () => { + const handleSubmit = jest.fn() + render( +
+ +
+ ) + + expect(screen.queryByTestId('use-ssh')).not.toBeInTheDocument() + }) + + it('should change Use SSH checkbox and show proper fields for password radio', async () => { + const handleSubmit = jest.fn() + render( +
+ +
+ ) + + fireEvent.click(screen.getByTestId('use-ssh')) + + expect(screen.getByTestId('sshHost')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toHaveValue('22') + expect(screen.getByTestId('sshPassword')).toBeInTheDocument() + expect(screen.queryByTestId('sshPrivateKey')).not.toBeInTheDocument() + expect(screen.queryByTestId('sshPassphrase')).not.toBeInTheDocument() + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + expect(submitBtn).toBeDisabled() + }) + + it('should change Use SSH checkbox and show proper fields for passphrase radio', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
+ +
+ ) + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + fireEvent.click( + container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + ) + }) + + expect(screen.getByTestId('sshHost')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toHaveValue('22') + expect(screen.queryByTestId('sshPassword')).not.toBeInTheDocument() + expect(screen.getByTestId('sshPrivateKey')).toBeInTheDocument() + expect(screen.getByTestId('sshPassphrase')).toBeInTheDocument() + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + expect(submitBtn).toBeDisabled() + }) + + it('should be proper validation for ssh via ssh password', async () => { + const handleSubmit = jest.fn() + render( +
+ +
+ ) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + }) + + it('should be proper validation for ssh via ssh passphrase', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
+ +
+ ) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + fireEvent.click( + container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshPrivateKey'), + { target: { value: 'PRIVATEKEY' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + }) + + it('should call submit btn with proper fields', async () => { + const handleSubmit = jest.fn() + render( +
+ +
+ ) + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + }) + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + + fireEvent.change( + screen.getByTestId('sshPort'), + { target: { value: '1771' } } + ) + + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + + fireEvent.change( + screen.getByTestId('sshPassword'), + { target: { value: '123' } } + ) + }) + + await act(() => { + fireEvent.click(screen.getByTestId(BTN_SUBMIT)) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sshHost: 'localhost', + sshPort: '1771', + sshUsername: 'username', + sshPassword: '123', + }) + ) + }) + + it('should call submit btn with proper fields via passphrase', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
+ +
+ ) + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + fireEvent.click( + container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + ) + }) + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + + fireEvent.change( + screen.getByTestId('sshPort'), + { target: { value: '1771' } } + ) + + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + + fireEvent.change( + screen.getByTestId('sshPrivateKey'), + { target: { value: '123444' } } + ) + + fireEvent.change( + screen.getByTestId('sshPassphrase'), + { target: { value: '123444' } } + ) + }) + + await act(() => { + fireEvent.click(screen.getByTestId(BTN_SUBMIT)) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sshHost: 'localhost', + sshPort: '1771', + sshUsername: 'username', + sshPrivateKey: '123444', + sshPassphrase: '123444', + }) + ) + }) + + it('should render password input with 10_000 length limit', () => { + render( + + ) + + expect(screen.getByTestId('password')).toHaveAttribute('maxLength', '10000') + }) + + it('should render security fields with proper attributes', () => { + render( + + ) + + expect(screen.getByTestId('password')).toHaveAttribute('value', '••••••••••••') + expect(screen.getByTestId('password')).toHaveAttribute('type', 'password') + expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '••••••••••••') + expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('type', 'password') + + fireEvent.focus(screen.getByTestId('password')) + fireEvent.focus(screen.getByTestId('sshPassphrase')) + + expect(screen.getByTestId('password')).toHaveAttribute('value', '') + expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '') + }) + + it('should render ssh password with proper attributes', () => { + render( + + ) + + expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '••••••••••••') + expect(screen.getByTestId('sshPassword')).toHaveAttribute('type', 'password') + + fireEvent.focus(screen.getByTestId('sshPassword')) + + expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '') + }) + + it('should render ssh password input with 10_000 length limit', () => { + render( + + ) + + expect(screen.getByTestId('sshPassword')).toHaveAttribute('maxLength', '10000') + }) + + describe('timeout', () => { + it('should render timeout input with 7 length limit and 1_000_000 value', () => { + render( + + ) + + expect(screen.getByTestId('timeout')).toBeInTheDocument() + expect(screen.getByTestId('timeout')).toHaveAttribute('maxLength', '7') + + fireEvent.change( + screen.getByTestId('timeout'), + { target: { value: '2000000' } } + ) + + expect(screen.getByTestId('timeout')).toHaveAttribute('value', '1000000') + }) + + it('should put only numbers', () => { + render( + + ) + + fireEvent.change( + screen.getByTestId('timeout'), + { target: { value: '11a2EU$#@' } } + ) + + expect(screen.getByTestId('timeout')).toHaveAttribute('value', '112') + }) + }) + + describe('cloud', () => { + it('some fields should be readonly if instance data source from cloud', () => { + (appRedirectionSelector as jest.Mock).mockImplementation(() => ({ + action: UrlHandlingActions.Connect, + })) + + const { queryByTestId } = render( + + ) + + expect(queryByTestId('connection-type')).not.toBeInTheDocument() + expect(queryByTestId('host')).not.toBeInTheDocument() + expect(queryByTestId('port')).not.toBeInTheDocument() + expect(queryByTestId('db-info-port')).toBeInTheDocument() + expect(queryByTestId('db-info-host')).toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.spec.tsx b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.spec.tsx new file mode 100644 index 0000000000..610bae62f9 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.spec.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import SentinelConnectionForm, { Props } from './SentinelConnectionForm' + +const mockedProps = mock() + +describe('SentinelConnectionForm', () => { + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx index 0cf13ed72e..f72040a3d2 100644 --- a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx @@ -48,7 +48,7 @@ const getInitFieldsDisplayNames = ({ host, port }: any) => { const SentinelConnectionForm = (props: Props) => { const { - initialValues, + initialValues = {}, width, onClose, onSubmit, diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx index eda2a1f610..dc2fa59483 100644 --- a/redisinsight/ui/src/pages/home/utils/form.tsx +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -168,6 +168,21 @@ export const getFormErrors = (values: DbConnectionInfo) => { } } + if (values.ssh) { + if (!values.sshHost) { + errs.sshHost = fieldDisplayNames.sshHost + } + if (!values.sshPort) { + errs.sshPort = fieldDisplayNames.sshPort + } + if (!values.sshUsername) { + errs.sshUsername = fieldDisplayNames.sshUsername + } + if (values.sshPassType === SshPassType.PrivateKey && !values.sshPrivateKey) { + errs.sshPrivateKey = fieldDisplayNames.sshPrivateKey + } + } + return errs } From f283049db4534a1affbf80377af6e8e750056318 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 1 Nov 2023 10:32:43 +0100 Subject: [PATCH 16/96] fixes by pr comments --- tests/e2e/common-actions/browser-actions.ts | 64 ++++++++++++------- .../critical-path/tree-view/delimiter.e2e.ts | 2 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/tests/e2e/common-actions/browser-actions.ts b/tests/e2e/common-actions/browser-actions.ts index 847537a906..a9275cffba 100644 --- a/tests/e2e/common-actions/browser-actions.ts +++ b/tests/e2e/common-actions/browser-actions.ts @@ -59,38 +59,54 @@ export class BrowserActions { } /** - * Check tree view structure - * @folders name of folders for tree view build - * @delimiter string with delimiter value - * @commonKeyFolder flag if not patterned keys will be displayed + * Verify that not patterned keys not visible with delimiter + * @param delimiter string with delimiter value */ - async checkTreeViewFoldersStructure(folders: string[][], delimiter: string, commonKeyFolder: boolean): Promise { - // Verify that all keys that are not inside of tree view doesn't contain delimiter - if (commonKeyFolder) { - const notPatternedKeys = Selector('[data-testid^="badge"]').parent('[data-testid^="node-item_"]'); - const notPatternedKeysNumber = await notPatternedKeys.count; - for (let i = 0; i < notPatternedKeysNumber; i++) { - await t.expect(notPatternedKeys.nth(i).withText(delimiter).exists).notOk('Not contained delimiter keys'); - } + async verifyNotPatternedKeys(delimiter: string): Promise { + const notPatternedKeys = Selector('[data-testid^="badge"]').parent('[data-testid^="node-item_"]'); + const notPatternedKeysNumber = await notPatternedKeys.count; + + for (let i = 0; i < notPatternedKeysNumber; i++) { + await t.expect(notPatternedKeys.nth(i).withText(delimiter).exists).notOk('Not contained delimiter keys'); } - // Verify that every level of tree view is clickable + } + + /** + * Get not added in array folder selector + * @param array folders selectors + * @param folderName name of folder + * @param delimiter string with delimiter value + */ + getFolderSelector(array: string[], folderName: string, delimiter: string): string { + if (array.length === 0) { + return `[data-testid="node-item_${folderName}${delimiter}"]`; + } + + const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); + return `${lastSelector}${folderName}${delimiter}"]`; + } + + /** + * Check tree view structure + * @param folders name of folders for tree view build + * @param delimiter string with delimiter value + */ + async checkTreeViewFoldersStructure(folders: string[][], delimiter: string): Promise { + // Verify not patterned keys + await this.verifyNotPatternedKeys(delimiter); + const foldersNumber = folders.length; + for (let i = 0; i < foldersNumber; i++) { const innerFoldersNumber = folders[i].length; const array: string[] = []; + for (let j = 0; j < innerFoldersNumber; j++) { - if (j === 0) { - const folderSelector = `[data-testid="node-item_${folders[i][j]}${delimiter}"]`; - array.push(folderSelector); - await t.click(Selector(folderSelector)); - } - else { - const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); - const folderSelector = `${lastSelector}${folders[i][j]}${delimiter}"]`; - array.push(folderSelector); - await t.click(Selector(folderSelector)); - } + const folderSelector = this.getFolderSelector(array, folders[i][j], delimiter); + array.push(folderSelector); + await t.click(Selector(folderSelector)); } + // Verify that the last folder level contains required keys const foundKeyName = `${folders[i].join(delimiter)}`; await t diff --git a/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts b/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts index e96d37957f..db1e2ffa7c 100644 --- a/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts +++ b/tests/e2e/tests/web/critical-path/tree-view/delimiter.e2e.ts @@ -37,5 +37,5 @@ test('Verify that user can see that input is not saved when the Cancel button is // Change delimiter await browserPage.TreeView.changeDelimiterInTreeView('-'); // Verify that when user changes the delimiter and clicks on Save button delimiter is applied - await browserActions.checkTreeViewFoldersStructure([['device_us', 'west'], ['mobile_eu', 'central'], ['mobile_us', 'east'], ['user_us', 'west'], ['device_eu', 'central'], ['user_eu', 'central']], '-', true); + await browserActions.checkTreeViewFoldersStructure([['device_us', 'west'], ['mobile_eu', 'central'], ['mobile_us', 'east'], ['user_us', 'west'], ['device_eu', 'central'], ['user_eu', 'central']], '-'); }); From 7e4a83917226238734728a6073861ce526542496 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 1 Nov 2023 11:33:18 +0100 Subject: [PATCH 17/96] fix by pr comments --- tests/e2e/common-actions/browser-actions.ts | 33 +++++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/e2e/common-actions/browser-actions.ts b/tests/e2e/common-actions/browser-actions.ts index a9275cffba..836fa6235e 100644 --- a/tests/e2e/common-actions/browser-actions.ts +++ b/tests/e2e/common-actions/browser-actions.ts @@ -72,18 +72,22 @@ export class BrowserActions { } /** - * Get not added in array folder selector - * @param array folders selectors + * Get node name by folders + * @param startFolder start folder * @param folderName name of folder * @param delimiter string with delimiter value */ - getFolderSelector(array: string[], folderName: string, delimiter: string): string { - if (array.length === 0) { - return `[data-testid="node-item_${folderName}${delimiter}"]`; - } + getNodeName(startFolder: string, folderName: string, delimiter: string): string { + return startFolder + folderName + delimiter; + + } - const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); - return `${lastSelector}${folderName}${delimiter}"]`; + /** + * Get node selector by name + * @param name node name + */ + getNodeSelector(name: string): Selector { + return Selector(`[data-testid="node-item_${name}"]`); } /** @@ -99,19 +103,22 @@ export class BrowserActions { for (let i = 0; i < foldersNumber; i++) { const innerFoldersNumber = folders[i].length; - const array: string[] = []; + let prevNodeSelector = ''; for (let j = 0; j < innerFoldersNumber; j++) { - const folderSelector = this.getFolderSelector(array, folders[i][j], delimiter); - array.push(folderSelector); - await t.click(Selector(folderSelector)); + const nodeName = this.getNodeName(prevNodeSelector, folders[i][j], delimiter); + const node = this.getNodeSelector(nodeName); + await t.click(node); + prevNodeSelector = nodeName; } // Verify that the last folder level contains required keys const foundKeyName = `${folders[i].join(delimiter)}`; + const firstFolderName = this.getNodeName('', folders[i][0], delimiter); + const firstFolder = this.getNodeSelector(firstFolderName); await t .expect(Selector(`[data-testid*="node-item_${foundKeyName}"]`).find('[data-testid^="key-"]').exists).ok('Specific key not found') - .click(array[0]); + .click(firstFolder); } } } From 0e25648344327e6f42a2207506ed70e250a960c3 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 2 Nov 2023 12:52:54 +0400 Subject: [PATCH 18/96] #RI-5009 - resolve comments --- redisinsight/ui/src/pages/home/HomePage.tsx | 6 +- .../DatabasePanel.spec.tsx} | 8 +- .../DatabasePanel.tsx} | 4 +- .../InstanceConnections.spec.tsx | 0 .../InstanceConnections.tsx | 0 .../home/components/DatabasePanel/index.ts | 3 + .../styles.module.scss | 0 .../home/components/Form/DatabaseForm.tsx | 52 +++--- .../ManualConnectionForm.tsx | 158 +++++------------- .../ManualConnectionFrom.spec.tsx | 12 -- .../ManualConnectionWrapper.tsx | 79 ++------- .../pages/home/components/RightPanel/index.ts | 3 - .../SentinelConnectionForm.tsx | 8 +- .../ui/src/pages/home/constants/form.ts | 3 + .../ui/src/pages/home/interfaces/form.ts | 5 + redisinsight/ui/src/pages/home/utils/form.tsx | 53 +++++- .../edit-connection/EditConnection.tsx | 4 +- 17 files changed, 162 insertions(+), 236 deletions(-) rename redisinsight/ui/src/pages/home/components/{RightPanel/RightPanel.spec.tsx => DatabasePanel/DatabasePanel.spec.tsx} (68%) rename redisinsight/ui/src/pages/home/components/{RightPanel/RightPanel.tsx => DatabasePanel/DatabasePanel.tsx} (98%) rename redisinsight/ui/src/pages/home/components/{RightPanel => DatabasePanel}/InstanceConnections/InstanceConnections.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{RightPanel => DatabasePanel}/InstanceConnections/InstanceConnections.tsx (100%) create mode 100644 redisinsight/ui/src/pages/home/components/DatabasePanel/index.ts rename redisinsight/ui/src/pages/home/components/{RightPanel => DatabasePanel}/styles.module.scss (100%) delete mode 100644 redisinsight/ui/src/pages/home/components/RightPanel/index.ts diff --git a/redisinsight/ui/src/pages/home/HomePage.tsx b/redisinsight/ui/src/pages/home/HomePage.tsx index 08af6d8c41..3e05265023 100644 --- a/redisinsight/ui/src/pages/home/HomePage.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.tsx @@ -2,6 +2,7 @@ import { EuiPage, EuiPageBody, EuiResizableContainer, EuiResizeObserver } from ' import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' +import DatabasePanel from 'redisinsight/ui/src/pages/home/components/DatabasePanel' import { clusterSelector, resetDataRedisCluster, resetInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' import { Nullable, setTitle } from 'uiSrc/utils' import { PageHeader } from 'uiSrc/components' @@ -26,7 +27,6 @@ import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPag import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' import { AddDbType } from 'uiSrc/pages/home/constants' -import RightPanel from 'uiSrc/pages/home/components/RightPanel' import DatabasesList from './components/DatabasesListComponent' import WelcomeComponent from './components/WelcomeComponent' import HomeHeader from './components/HomeHeader' @@ -262,7 +262,7 @@ const HomePage = () => { style={{ minWidth: '494px' }} > {!!openRightPanel && ( - { ) : ( <> {openRightPanel === RightPanelName.AddDatabase && ( - () -describe('AddDatabasesContainer', () => { +describe('DatabasePanel', () => { it('should render', () => { expect( - render() + render() ).toBeTruthy() }) it('should render instance types after click on auto discover', () => { - render() + render() fireEvent.click(screen.getByTestId('add-auto')) expect(screen.getByTestId('db-types')).toBeInTheDocument() }) diff --git a/redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.tsx b/redisinsight/ui/src/pages/home/components/DatabasePanel/DatabasePanel.tsx similarity index 98% rename from redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.tsx rename to redisinsight/ui/src/pages/home/components/DatabasePanel/DatabasePanel.tsx index 305178786e..b93409759b 100644 --- a/redisinsight/ui/src/pages/home/components/RightPanel/RightPanel.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasePanel/DatabasePanel.tsx @@ -43,7 +43,7 @@ export interface Props { initConnectionType?: AddDbType } -const RightPanel = React.memo((props: Props) => { +const DatabasePanel = React.memo((props: Props) => { const { editMode, isResizablePanel, @@ -233,4 +233,4 @@ const RightPanel = React.memo((props: Props) => { ) }) -export default RightPanel +export default DatabasePanel diff --git a/redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.spec.tsx b/redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.spec.tsx rename to redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.tsx b/redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/RightPanel/InstanceConnections/InstanceConnections.tsx rename to redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasePanel/index.ts b/redisinsight/ui/src/pages/home/components/DatabasePanel/index.ts new file mode 100644 index 0000000000..18eecda936 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/DatabasePanel/index.ts @@ -0,0 +1,3 @@ +import DatabasePanel from './DatabasePanel' + +export default DatabasePanel diff --git a/redisinsight/ui/src/pages/home/components/RightPanel/styles.module.scss b/redisinsight/ui/src/pages/home/components/DatabasePanel/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/RightPanel/styles.module.scss rename to redisinsight/ui/src/pages/home/components/DatabasePanel/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx b/redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx index c64a440d57..fe839b983c 100644 --- a/redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx +++ b/redisinsight/ui/src/pages/home/components/Form/DatabaseForm.tsx @@ -14,20 +14,32 @@ import { import { BuildType } from 'uiSrc/constants/env' import { SECURITY_FIELD } from 'uiSrc/constants' import { appInfoSelector } from 'uiSrc/slices/app/info' -import { handlePasteHostName, MAX_PORT_NUMBER, MAX_TIMEOUT_NUMBER, selectOnFocus, validateField, validatePortNumber, validateTimeoutNumber } from 'uiSrc/utils' -import { ConnectionType, InstanceType } from 'uiSrc/slices/interfaces' -import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' +import { + handlePasteHostName, + MAX_PORT_NUMBER, + MAX_TIMEOUT_NUMBER, + selectOnFocus, + validateField, + validatePortNumber, + validateTimeoutNumber, +} from 'uiSrc/utils' +import { DbConnectionInfo, IPasswordType } from 'uiSrc/pages/home/interfaces' + +interface IShowFields { + alias: boolean + host: boolean + port: boolean + timeout: boolean +} export interface Props { flexGroupClassName?: string flexItemClassName?: string formik: FormikProps - isEditMode?: boolean - isCloneMode?: boolean onHostNamePaste: (content: string) => boolean - instanceType: InstanceType - connectionType?: ConnectionType - isFromCloud?: boolean + showFields: IShowFields + autoFocus?: boolean + passwordType?: IPasswordType } const DatabaseForm = (props: Props) => { @@ -35,12 +47,10 @@ const DatabaseForm = (props: Props) => { flexGroupClassName = '', flexItemClassName = '', formik, - isEditMode = false, - isCloneMode = false, onHostNamePaste, - instanceType, - connectionType, - isFromCloud, + autoFocus = false, + showFields, + passwordType = IPasswordType.Password, } = props const { server } = useSelector(appInfoSelector) @@ -84,11 +94,11 @@ const DatabaseForm = (props: Props) => { return ( <> - {(!isEditMode || isCloneMode) && !isFromCloud && ( + {showFields.host && ( { )} - {server?.buildType !== BuildType.RedisStack && !isFromCloud && ( + {server?.buildType !== BuildType.RedisStack && showFields.port && ( { )} - {( - (!isEditMode || isCloneMode) - && instanceType !== InstanceType.Sentinel - && connectionType !== ConnectionType.Sentinel - ) && ( + {showFields.alias && ( @@ -179,7 +185,7 @@ const DatabaseForm = (props: Props) => { { - {connectionType !== ConnectionType.Sentinel && instanceType !== InstanceType.Sentinel && ( + {showFields.timeout && ( void - initialValues: DbConnectionInfo onSubmit: (values: DbConnectionInfo) => void onTestConnection: (values: DbConnectionInfo) => void onHostNamePaste: (content: string) => boolean @@ -77,49 +75,15 @@ export interface Props { } const getInitFieldsDisplayNames = ({ host, port, name }: any) => { - if ((!host || !port) && !name) { + if (!host || !port || !name) { return pick(fieldDisplayNames, ['host', 'port', 'name']) } return {} } -const getDefaultHost = () => '127.0.0.1' -const getDefaultPort = () => '6379' - const ManualConnectionForm = (props: Props) => { const { - formFields: { - id, - host, - name, - port, - tls, - db = null, - compressor = NONE, - nameFromProvider, - sentinelMaster, - connectionType, - nodes = null, - tlsClientAuthRequired, - certificates, - selectedTlsClientCertId = '', - verifyServerTlsCert, - caCertificates, - selectedCaCertName, - username, - password, - timeout, - modules, - sentinelMasterPassword, - sentinelMasterUsername, - servername, - provider, - ssh, - sshPassType = SshPassType.Password, - sshOptions, - version, - }, - initialValues: initialValuesProp, + formFields, width, onClose, onSubmit, @@ -134,59 +98,30 @@ const ManualConnectionForm = (props: Props) => { onAliasEdited, } = props - const { contextInstanceId, lastPage } = useSelector(appContextSelector) - const { action } = useSelector(appRedirectionSelector) - - const prepareInitialValues = () => ({ - host: host ?? getDefaultHost(), - port: port ? port.toString() : getDefaultPort(), - timeout: timeout ? timeout.toString() : toString(DEFAULT_TIMEOUT / 1_000), - name: name ?? `${getDefaultHost()}:${getDefaultPort()}`, - username, - password, - tls, - db, - compressor, + const { + id, + host, + name, + port, + db = null, + nameFromProvider, + sentinelMaster, + connectionType, + nodes = null, modules, - showDb: !!db, - showCompressor: compressor !== NONE, - sni: !!servername, - servername, - newCaCert: '', - newCaCertName: '', - selectedCaCertName, - tlsClientAuthRequired, - verifyServerTlsCert, - newTlsCertPairName: '', - selectedTlsClientCertId, - newTlsClientCert: '', - newTlsClientKey: '', - sentinelMasterName: sentinelMaster?.name || '', - sentinelMasterUsername, - sentinelMasterPassword, - ssh, - sshPassType, - sshHost: sshOptions?.host ?? '', - sshPort: sshOptions?.port ?? 22, - sshUsername: sshOptions?.username ?? '', - sshPassword: sshOptions?.password ?? '', - sshPrivateKey: sshOptions?.privateKey ?? '', - sshPassphrase: sshOptions?.passphrase ?? '' - }) + provider, + version, + } = formFields - const [initialValues, setInitialValues] = useState(prepareInitialValues()) + const { contextInstanceId, lastPage } = useSelector(appContextSelector) + const { action } = useSelector(appRedirectionSelector) + const { data: caCertificates } = useSelector(caCertsSelector) + const { data: certificates } = useSelector(clientCertsSelector) const [errors, setErrors] = useState>( getInitFieldsDisplayNames({ host, port, name }) ) - useEffect(() => { - const values = prepareInitialValues() - - setInitialValues(values) - formik.setValues(values) - }, [initialValuesProp, isCloneMode]) - const history = useHistory() const dispatch = useDispatch() @@ -211,7 +146,7 @@ const ManualConnectionForm = (props: Props) => { } const formik = useFormik({ - initialValues, + initialValues: formFields, validate, enableReinitialize: true, onSubmit: (values: any) => { @@ -313,7 +248,7 @@ const ManualConnectionForm = (props: Props) => { history.push(Pages.workbench(id)) return } - history.push(Pages.browser(id)) + history.push(Pages.browser(id ?? '')) } const SubmitButton = ({ @@ -385,14 +320,14 @@ const ManualConnectionForm = (props: Props) => { {onClose && ( - - Cancel - + + Cancel + )} { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} - isCloneMode={isCloneMode} - isEditMode={isEditMode} - connectionType={connectionType} - instanceType={InstanceType.Standalone} onHostNamePaste={onHostNamePaste} + showFields={{ host: true, alias: true, port: true, timeout: true }} /> { > {isCloneMode && ( @@ -578,10 +512,7 @@ const ManualConnectionForm = (props: Props) => { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} - isCloneMode={isCloneMode} - isEditMode={isEditMode} - connectionType={connectionType} - instanceType={InstanceType.Standalone} + showFields={{ host: false, port: true, alias: false, timeout: false }} onHostNamePaste={onHostNamePaste} /> @@ -631,10 +562,7 @@ const ManualConnectionForm = (props: Props) => { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} - isCloneMode={isCloneMode} - isEditMode={isEditMode} - connectionType={connectionType} - instanceType={InstanceType.Standalone} + showFields={{ host: true, port: true, alias: false, timeout: false }} onHostNamePaste={onHostNamePaste} /> diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx index 88607e7c09..6cbfdbfde0 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx @@ -804,18 +804,6 @@ describe('InstanceForm', () => { expect(screen.getByTestId('port')).toHaveValue('6379') expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') }) - - // it('should render proper default values for sentinel', () => { - // render( - // - // ) - // expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') - // expect(screen.getByTestId('port')).toHaveValue('26379') - // }) }) it('should change Use SSH checkbox', async () => { diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx index e36fb9b484..25cb5409e4 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx @@ -1,4 +1,4 @@ -import { pick, toNumber, toString, omit } from 'lodash' +import { pick, toNumber, omit } from 'lodash' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router' @@ -14,17 +14,20 @@ import { import { Nullable, removeEmpty, getFormUpdates, transformQueryParamsObject } from 'uiSrc/utils' import { BuildType } from 'uiSrc/constants/env' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { caCertsSelector, fetchCaCerts } from 'uiSrc/slices/instances/caCerts' +import { fetchCaCerts } from 'uiSrc/slices/instances/caCerts' import { ConnectionType, Instance, InstanceType } from 'uiSrc/slices/interfaces' import { DbType, Pages } from 'uiSrc/constants' -import { clientCertsSelector, fetchClientCerts, } from 'uiSrc/slices/instances/clientCerts' +import { fetchClientCerts, } from 'uiSrc/slices/instances/clientCerts' import { appInfoSelector } from 'uiSrc/slices/app/info' import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { getRedirectionPage } from 'uiSrc/utils/routing' import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' -import { applyTlSDatabase, applySSHDatabase, autoFillFormDetails, getTlsSettings } from 'uiSrc/pages/home/utils' -import { ADD_NEW, DEFAULT_TIMEOUT, NO_CA_CERT, SshPassType, SubmitBtnText } from 'uiSrc/pages/home/constants' +import { applyTlSDatabase, applySSHDatabase, autoFillFormDetails, getTlsSettings, getFormValues } from 'uiSrc/pages/home/utils' +import { + DEFAULT_TIMEOUT, + SubmitBtnText, +} from 'uiSrc/pages/home/constants' import ManualConnectionForm from './ManualConnectionForm' export interface Props { @@ -38,24 +41,6 @@ export interface Props { onAliasEdited?: (value: string) => void } -const getInitialValues = (editedInstance?: Nullable>) => ({ - // undefined - to show default value, empty string - for existing db - host: editedInstance?.host ?? (editedInstance ? '' : undefined), - port: editedInstance?.port?.toString() ?? (editedInstance ? '' : undefined), - name: editedInstance?.name ?? (editedInstance ? '' : undefined), - username: editedInstance?.username ?? '', - password: editedInstance?.password ?? '', - timeout: editedInstance?.timeout - ? toString(editedInstance?.timeout / 1_000) - : (editedInstance ? '' : undefined), - tls: !!editedInstance?.tls ?? false, - ssh: !!editedInstance?.ssh ?? false, - servername: editedInstance?.tlsServername, - sshPassType: editedInstance?.sshOptions - ? (editedInstance.sshOptions.privateKey ? SshPassType.PrivateKey : SshPassType.Password) - : SshPassType.Password -}) - const ManualConnectionWrapper = (props: Props) => { const { editMode, @@ -67,24 +52,14 @@ const ManualConnectionWrapper = (props: Props) => { urlHandlingAction, initialValues: initialValuesProp } = props - const [initialValues, setInitialValues] = useState(getInitialValues(editedInstance || initialValuesProp)) - const [isCloneMode, setIsCloneMode] = useState(false) + const [formFields, setFormFields] = useState(getFormValues(editedInstance || initialValuesProp)) - const { host, port, name, username, password, timeout, tls, ssh, sshPassType, servername } = initialValues + const [isCloneMode, setIsCloneMode] = useState(false) const { loadingChanging: loadingStandalone } = useSelector(instancesSelector) - const { data: caCertificates } = useSelector(caCertsSelector) - const { data: certificates } = useSelector(clientCertsSelector) const { server } = useSelector(appInfoSelector) const { properties: urlHandlingProperties } = useSelector(appRedirectionSelector) - const tlsClientAuthRequired = !!editedInstance?.clientCert?.id ?? false - const selectedTlsClientCertId = editedInstance?.clientCert?.id ?? ADD_NEW - const verifyServerTlsCert = editedInstance?.verifyServerCert ?? false - const selectedCaCertName = editedInstance?.caCert?.id ?? NO_CA_CERT - const sentinelMasterUsername = editedInstance?.sentinelMaster?.username ?? '' - const sentinelMasterPassword = editedInstance?.sentinelMaster?.password ?? '' - const connectionType = editedInstance?.connectionType ?? DbType.STANDALONE const masterName = editedInstance?.sentinelMaster?.name @@ -97,10 +72,7 @@ const ManualConnectionWrapper = (props: Props) => { }, []) useEffect(() => { - (editedInstance || initialValuesProp) && setInitialValues({ - ...initialValues, - ...getInitialValues(editedInstance || initialValuesProp) - }) + setFormFields(getFormValues(editedInstance || initialValuesProp || null)) setIsCloneMode(false) }, [editedInstance, initialValuesProp]) @@ -322,29 +294,6 @@ const ManualConnectionWrapper = (props: Props) => { onClose?.() } - const connectionFormData = { - ...editedInstance, - name, - host, - port, - tls, - username, - password, - timeout, - connectionType, - tlsClientAuthRequired, - certificates, - selectedTlsClientCertId, - caCertificates, - verifyServerTlsCert, - selectedCaCertName, - sentinelMasterUsername, - sentinelMasterPassword, - ssh, - sshPassType, - servername, - } - const getSubmitButtonText = () => { if (isCloneMode) { return SubmitBtnText.CloneDatabase @@ -356,15 +305,15 @@ const ManualConnectionWrapper = (props: Props) => { } const handlePostHostName = (content: string): boolean => ( - autoFillFormDetails(content, initialValues, setInitialValues, InstanceType.Standalone) + autoFillFormDetails(content, formFields, setFormFields, InstanceType.Standalone) ) return (
{ formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} - connectionType={ConnectionType.Sentinel} - instanceType={InstanceType.Sentinel} + passwordType={IPasswordType.dual} + showFields={{ host: true, port: true, alias: false, timeout: false }} onHostNamePaste={onHostNamePaste} - isFromCloud={false} /> ({ useTls: values.tls, @@ -277,3 +285,44 @@ export const getSubmitButtonContent = (errors: FormikErrors, s {errorsArr} ) : null } + +export const getFormValues = (instance: Nullable>) => ({ + host: instance?.host ?? (instance ? '' : DEFAULT_HOST), + port: instance?.port?.toString() ?? (instance ? '' : DEFAULT_PORT), + timeout: instance?.timeout + ? toString(instance?.timeout / 1_000) + : toString(DEFAULT_TIMEOUT / 1_000), + name: instance?.name ?? (instance ? '' : DEFAULT_ALIAS), + username: instance?.username ?? '', + password: instance?.password ?? '', + tls: instance?.tls ?? false, + db: instance?.db, + compressor: instance?.compressor ?? NONE, + modules: instance?.modules, + showDb: !!instance?.db, + showCompressor: instance && instance.compressor !== NONE, + sni: !!instance?.servername, + servername: instance?.servername, + newCaCert: '', + newCaCertName: '', + selectedCaCertName: instance?.caCert?.id ?? NO_CA_CERT, + tlsClientAuthRequired: instance?.clientCert?.id ?? false, + verifyServerTlsCert: instance?.verifyServerCert ?? false, + newTlsCertPairName: '', + selectedTlsClientCertId: instance?.clientCert?.id ?? ADD_NEW, + newTlsClientCert: '', + newTlsClientKey: '', + sentinelMasterName: instance?.sentinelMaster?.name || '', + sentinelMasterUsername: instance?.sentinelMasterUsername, + sentinelMasterPassword: instance?.sentinelMasterPassword, + ssh: instance?.ssh ?? false, + sshPassType: instance?.sshOptions + ? (instance.sshOptions.privateKey ? SshPassType.PrivateKey : SshPassType.Password) + : SshPassType.Password, + sshHost: instance?.sshOptions?.host ?? '', + sshPort: instance?.sshOptions?.port ?? 22, + sshUsername: instance?.sshOptions?.username ?? '', + sshPassword: instance?.sshOptions?.password ?? '', + sshPrivateKey: instance?.sshOptions?.privateKey ?? '', + sshPassphrase: instance?.sshOptions?.passphrase ?? '' +}) diff --git a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx index c167d4b33a..0d8d34eaeb 100644 --- a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx +++ b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx @@ -18,7 +18,7 @@ import { HELP_LINKS } from 'uiSrc/pages/home/constants' import { sendEventTelemetry } from 'uiSrc/telemetry' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' -import RightPanel from 'uiSrc/pages/home/components/RightPanel/RightPanel' +import DatabasePanel from 'uiSrc/pages/home/components/DatabasePanel/DatabasePanel' import './styles.scss' import styles from './styles.module.scss' @@ -119,7 +119,7 @@ const EditConnection = () => { )}
- Date: Fri, 3 Nov 2023 16:51:41 +0400 Subject: [PATCH 19/96] #RI-5082 - replace Redis Enterprise Cloud with Redis CLoud --- .../FilterNotAvailable.tsx | 2 +- .../module-not-loaded/ModuleNotLoaded.tsx | 2 +- .../infinite-messages/InfiniteMessages.tsx | 6 +++--- .../OAuthSelectAccountDialog.tsx | 2 +- .../OAuthSignInDialog.tsx | 2 +- .../oauth/oauth-social/OAuthSocial.tsx | 2 +- .../constants/mocks/mock-recommendations.ts | 20 +++++++++---------- .../DatabasesListWrapper.tsx | 2 +- .../WelcomeComponent/WelcomeComponent.tsx | 2 +- .../RedisCloudDatabases.tsx | 4 ++-- .../RedisCloudDatabasesPage.tsx | 2 +- .../RedisCloudSubscriptions.tsx | 4 ++-- .../RedisCloudSubscriptionsPage.tsx | 2 +- .../ui/src/pages/settings/SettingsPage.tsx | 2 +- .../cloud-settings/CloudSettings.tsx | 10 +++++----- .../user-api-keys-table/UserApiKeysTable.tsx | 8 ++++---- .../ui/src/slices/interfaces/instances.ts | 2 +- .../src/utils/oauth/parseCloudOAuthError.tsx | 10 +++++----- .../tests/oauth/parseCloudOAuthError.spec.tsx | 10 +++++----- tests/e2e/helpers/database.ts | 4 ++-- .../web/smoke/database/autodiscover-db.e2e.ts | 4 ++-- 21 files changed, 51 insertions(+), 51 deletions(-) diff --git a/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx b/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx index 919ccfbc46..a81e212f7f 100644 --- a/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx +++ b/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx @@ -28,7 +28,7 @@ const FilterNotAvailable = ({ onClose } : { onClose?: () => void }) => { {!!freeInstance && ( <> - Use your free all-in-one Redis Enterprise Cloud database to start exploring these capabilities. + Use your free all-in-one Redis Cloud database to start exploring these capabilities. diff --git a/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx index 70e610327c..ef0cd8993b 100644 --- a/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx +++ b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx @@ -84,7 +84,7 @@ const ModuleNotLoaded = ({ moduleName, id, type = 'workbench', onClose }: IProps ) : ( - Use your free all-in-one Redis Enterprise Cloud database to start exploring these capabilities. + Use your free all-in-one Redis Cloud database to start exploring these capabilities. )), [freeInstance]) diff --git a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx index 67b385e810..1d4cf08fb6 100644 --- a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx @@ -55,7 +55,7 @@ export const INFINITE_MESSAGES = { > Congratulations! - You can now use your Redis Stack database in Redis Enterprise Cloud + You can now use your Redis Stack database in Redis Cloud to start exploring all its developer capabilities via RedisInsight tutorials. @@ -84,7 +84,7 @@ export const INFINITE_MESSAGES = { onMouseUp={(e) => { e.preventDefault() }} data-testid="database-exists-notification" > - You already have a free Redis Enterprise Cloud subscription. + You already have a free Redis Cloud subscription. Do you want to import your existing database into RedisInsight? @@ -125,7 +125,7 @@ export const INFINITE_MESSAGES = { onMouseUp={(e) => { e.preventDefault() }} data-testid="subscription-exists-notification" > - Your subscription does not have a free Redis Enterprise Cloud database. + Your subscription does not have a free Redis Cloud database. Do you want to create a free database in your existing subscription? diff --git a/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx b/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx index af96459a90..7dcf44cb67 100644 --- a/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx @@ -125,7 +125,7 @@ const OAuthSelectAccountDialog = () => {
-

Connect to Redis Enterprise Cloud

+

Connect to Redis Cloud

Select an account to connect to: diff --git a/redisinsight/ui/src/components/oauth/oauth-sign-in-dialog/OAuthSignInDialog.tsx b/redisinsight/ui/src/components/oauth/oauth-sign-in-dialog/OAuthSignInDialog.tsx index feef06cb62..2b4eee2fa7 100644 --- a/redisinsight/ui/src/components/oauth/oauth-sign-in-dialog/OAuthSignInDialog.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-sign-in-dialog/OAuthSignInDialog.tsx @@ -44,7 +44,7 @@ const OAuthSignInDialog = () => {
-

Get started with Redis Enterprise Cloud

+

Get started with Redis Cloud

{OAuthAdvantages.map(({ icon, text, title }) => ( diff --git a/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx b/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx index ebbd8a8e63..e0a2afe6f7 100644 --- a/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx @@ -104,7 +104,7 @@ const OAuthSocial = ({ type = OAuthSocialType.Modal, hideTitle = false }: Props) Auto-discover subscriptions and add your databases.
- A new Redis Enterprise Cloud account will be created for you if you don’t have one. + A new Redis Cloud account will be created for you if you don’t have one.
{buttons} diff --git a/redisinsight/ui/src/constants/mocks/mock-recommendations.ts b/redisinsight/ui/src/constants/mocks/mock-recommendations.ts index 08ed30a555..3ab6677f76 100644 --- a/redisinsight/ui/src/constants/mocks/mock-recommendations.ts +++ b/redisinsight/ui/src/constants/mocks/mock-recommendations.ts @@ -414,7 +414,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -558,7 +558,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -719,7 +719,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -997,7 +997,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -1089,7 +1089,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -1218,7 +1218,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -1399,7 +1399,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -1479,7 +1479,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -1548,7 +1548,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { @@ -1625,7 +1625,7 @@ export const MOCK_RECOMMENDATIONS: IRecommendationsStatic = { type: 'link', value: { href: 'https://redis.com/redis-enterprise-cloud/overview/', - name: 'Redis Enterprise Cloud' + name: 'Redis Cloud' } }, { diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx index c163666a4e..15342e7be9 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx @@ -363,7 +363,7 @@ const DatabasesListWrapper = ({ <> {instance.cloudDetails && ( { buttons: [ { title: 'Import Redis Cloud database connections', - description: 'Sign in to your Redis Enterprise Cloud account to discover and add databases', + description: 'Sign in to your Redis Cloud account to discover and add databases', iconType: CloudIcon, iconClassName: styles.cloudIcon, feature: FeatureFlags.cloudSso, diff --git a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx index d45c5d5879..82235b5b98 100644 --- a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx +++ b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx @@ -186,7 +186,7 @@ const RedisCloudDatabasesPage = ({

- Redis Enterprise Cloud Databases + Redis Cloud Databases

@@ -195,7 +195,7 @@ const RedisCloudDatabasesPage = ({ These are {' '} {items.length > 1 ? 'databases ' : 'database '} - in your Redis Enterprise cloud. Select the + in your Redis Cloud. Select the {items.length > 1 ? ' databases ' : ' database '} {' '} that you diff --git a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx index f2037478ef..7460d02546 100644 --- a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx +++ b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx @@ -44,7 +44,7 @@ const RedisCloudDatabasesPage = () => { dataAdded: instancesAdded, } = useSelector(cloudSelector) - setTitle('Redis Enterprise Cloud Databases') + setTitle('Redis Cloud Databases') useEffect(() => { if (instances === null) { diff --git a/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx b/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx index e903928b8e..39fb13c49c 100644 --- a/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx +++ b/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx @@ -47,7 +47,7 @@ interface IPopoverProps { const loadingMsg = 'loading...' const notFoundMsg = 'Not found' -const noResultsMessage = 'Your Redis Enterprise Cloud has no subscriptions available.' +const noResultsMessage = 'Your Redis Cloud has no subscriptions available.' const RedisCloudSubscriptions = ({ subscriptions, @@ -244,7 +244,7 @@ const RedisCloudSubscriptions = ({
-

Redis Enterprise Cloud Subscriptions

+

Redis Cloud Subscriptions

0}> diff --git a/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx b/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx index ad45bbf034..c9acff8e1f 100644 --- a/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx +++ b/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx @@ -44,7 +44,7 @@ const RedisCloudSubscriptionsPage = () => { account: { error: accountError, data: account }, } = useSelector(cloudSelector) - setTitle('Redis Enterprise Cloud Subscriptions') + setTitle('Redis Cloud Subscriptions') useEffect(() => { if (subscriptions === null) { diff --git a/redisinsight/ui/src/pages/settings/SettingsPage.tsx b/redisinsight/ui/src/pages/settings/SettingsPage.tsx index 735874bd27..c388ad2cd7 100644 --- a/redisinsight/ui/src/pages/settings/SettingsPage.tsx +++ b/redisinsight/ui/src/pages/settings/SettingsPage.tsx @@ -187,7 +187,7 @@ const SettingsPage = () => { diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx b/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx index fa046e3cb2..f128441d22 100644 --- a/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/CloudSettings.tsx @@ -54,15 +54,15 @@ const CloudSettings = () => { The list of API user keys that are stored locally in RedisInsight.
- API user keys grant programmatic access to Redis Enterprise Cloud.
- {'To delete API keys from Redis Enterprise Cloud, '} + API user keys grant programmatic access to Redis Cloud.
+ {'To delete API keys from Redis Cloud, '} - sign in to Redis Enterprise Cloud + sign in to Redis Cloud {' and delete them manually.'}
@@ -93,7 +93,7 @@ const CloudSettings = () => {

All API user keys will be removed from RedisInsight.

- {'To delete API keys from Redis Enterprise Cloud, '} + {'To delete API keys from Redis Cloud, '} { tabIndex={-1} href="https://redis.com/redis-enterprise-cloud/overview/?utm_source=redisinsight&utm_medium=settings&utm_campaign=clear_keys" > - sign in to Redis Enterprise Cloud + sign in to Redis Cloud {' and delete them manually.'} diff --git a/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx index eed7cdf1a4..a78c881443 100644 --- a/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx +++ b/redisinsight/ui/src/pages/settings/components/cloud-settings/components/user-api-keys-table/UserApiKeysTable.tsx @@ -97,7 +97,7 @@ const UserApiKeysTable = ({ items, loading }: Props) => {
{!valid && ( @@ -165,7 +165,7 @@ const UserApiKeysTable = ({ items, loading }: Props) => { header={(<>{formatLongName(name)}
will be removed from RedisInsight.)} text={( <> - {'To delete this API key from Redis Enterprise Cloud, '} + {'To delete this API key from Redis Cloud, '} { tabIndex={-1} href="https://redis.com/redis-enterprise-cloud/overview/?utm_source=redisinsight&utm_medium=settings&utm_campaign=clear_keys" > - sign in to Redis Enterprise Cloud + sign in to Redis Cloud {' and delete it manually.'} @@ -207,7 +207,7 @@ const UserApiKeysTable = ({ items, loading }: Props) => { - Cloud API keys will be created and stored when you connect to Redis Enterprise Cloud to create + Cloud API keys will be created and stored when you connect to Redis Cloud to create a free Cloud database or autodiscover your Cloud database. diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 5df4bb4edf..3180a32641 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -494,7 +494,7 @@ export interface ICredentialsRedisCloud { export enum InstanceType { Standalone = 'Redis Database', - RedisCloudPro = 'Redis Enterprise Cloud', + RedisCloudPro = 'Redis Cloud', RedisEnterpriseCluster = 'Redis Enterprise Cluster', AWSElasticache = 'AWS Elasticache', Sentinel = 'Redis Sentinel', diff --git a/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx b/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx index e400b93f49..800ceea1f3 100644 --- a/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx +++ b/redisinsight/ui/src/utils/oauth/parseCloudOAuthError.tsx @@ -81,7 +81,7 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M title = 'Access denied' message = ( <> - You do not have permission to access Redis Enterprise Cloud. + You do not have permission to access Redis Cloud. ) break @@ -115,7 +115,7 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M title = 'Unauthorized' message = ( <> - Your Redis Enterprise Cloud authorization failed. + Your Redis Cloud authorization failed. Try again later. @@ -128,11 +128,11 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M title = 'Invalid API key' message = ( <> - Your Redis Enterprise Cloud authorization failed. + Your Redis Cloud authorization failed. Remove the invalid API key from RedisInsight and try again. - Open the Settings page to manage Redis Enterprise Cloud API keys. + Open the Settings page to manage Redis Cloud API keys. ) additionalInfo.resourceId = err.resourceId @@ -143,7 +143,7 @@ export const parseCloudOAuthError = (err: CustomError | string = DEFAULT_ERROR_M title = 'Database already exists' message = ( <> - You already have a free Redis Enterprise Cloud database running. + You already have a free Redis Cloud database running. Check out your Cloud console for connection details. diff --git a/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx b/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx index f0a53e790d..da432f6ebf 100644 --- a/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx +++ b/redisinsight/ui/src/utils/tests/oauth/parseCloudOAuthError.spec.tsx @@ -27,7 +27,7 @@ const parseCloudOAuthErrorTests = [ title: 'Access denied', message: ( <> - You do not have permission to access Redis Enterprise Cloud. + You do not have permission to access Redis Cloud. ) })], @@ -60,7 +60,7 @@ const parseCloudOAuthErrorTests = [ title: 'Unauthorized', message: ( <> - Your Redis Enterprise Cloud authorization failed. + Your Redis Cloud authorization failed. Try again later. @@ -78,7 +78,7 @@ const parseCloudOAuthErrorTests = [ title: 'Database already exists', message: ( <> - You already have a free Redis Enterprise Cloud database running. + You already have a free Redis Cloud database running. Check out your
Cloud console for connection details. @@ -89,11 +89,11 @@ const parseCloudOAuthErrorTests = [ title: 'Invalid API key', message: ( <> - Your Redis Enterprise Cloud authorization failed. + Your Redis Cloud authorization failed. Remove the invalid API key from RedisInsight and try again. - Open the Settings page to manage Redis Enterprise Cloud API keys. + Open the Settings page to manage Redis Cloud API keys. ), additionalInfo: { diff --git a/tests/e2e/helpers/database.ts b/tests/e2e/helpers/database.ts index b92538acc0..d4afdb1819 100644 --- a/tests/e2e/helpers/database.ts +++ b/tests/e2e/helpers/database.ts @@ -132,7 +132,7 @@ export class DatabaseHelper { } /** - * Add a new database from Redis Enterprise Cloud via auto-discover flow + * Add a new database from Redis Cloud via auto-discover flow * @param cloudAPIAccessKey The Cloud API Access Key * @param cloudAPISecretKey The Cloud API Secret Key */ @@ -151,7 +151,7 @@ export class DatabaseHelper { await t .expect( autoDiscoverREDatabases.title.withExactText( - 'Redis Enterprise Cloud Subscriptions' + 'Redis Cloud Subscriptions' ).exists ) .ok('Subscriptions list not displayed', { timeout: 120000 }); diff --git a/tests/e2e/tests/web/smoke/database/autodiscover-db.e2e.ts b/tests/e2e/tests/web/smoke/database/autodiscover-db.e2e.ts index 2c1d0e16a1..8b814104a6 100644 --- a/tests/e2e/tests/web/smoke/database/autodiscover-db.e2e.ts +++ b/tests/e2e/tests/web/smoke/database/autodiscover-db.e2e.ts @@ -47,11 +47,11 @@ test ); await t.click( myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); - await t.expect(autoDiscoverREDatabases.title.withExactText('Redis Enterprise Cloud Subscriptions').exists) + await t.expect(autoDiscoverREDatabases.title.withExactText('Redis Cloud Subscriptions').exists) .ok('Subscriptions list not displayed', { timeout: 120000 }); // Select subscriptions await t.click(myRedisDatabasePage.AddRedisDatabase.selectAllCheckbox); await t.click(myRedisDatabasePage.AddRedisDatabase.showDatabasesButton); - await t.expect(autoDiscoverREDatabases.title.withExactText('Redis Enterprise Cloud Databases').exists) + await t.expect(autoDiscoverREDatabases.title.withExactText('Redis Cloud Databases').exists) .ok('database page is not displayed', { timeout: 120000 }); }); From 8f93f77716a4dc7103ad28388aaa3b2df1d9b88b Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 6 Nov 2023 19:20:52 +0400 Subject: [PATCH 20/96] #RI-5094 - add telemetry in user properties --- .../analytics/analytics.service.spec.ts | 31 +++++++++++++++++++ .../modules/analytics/analytics.service.ts | 15 +++++++++ 2 files changed, 46 insertions(+) diff --git a/redisinsight/api/src/modules/analytics/analytics.service.spec.ts b/redisinsight/api/src/modules/analytics/analytics.service.spec.ts index 7c3241a3f5..e7aa5794c0 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.spec.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.spec.ts @@ -10,6 +10,7 @@ import { AppType } from 'src/modules/server/models/server'; import { SettingsService } from 'src/modules/settings/settings.service'; import { AnalyticsService, + Telemetry, NON_TRACKING_ANONYMOUS_ID, } from './analytics.service'; @@ -95,6 +96,11 @@ describe('AnalyticsService', () => { anonymousId: mockAnonymousId, integrations: { Amplitude: { session_id: sessionId } }, event: TelemetryEvents.ApplicationStarted, + context: { + traits: { + telemetry: Telemetry.Enabled, + }, + }, properties: { anonymousId: mockAnonymousId, buildType: AppType.Electron, @@ -128,6 +134,11 @@ describe('AnalyticsService', () => { anonymousId: NON_TRACKING_ANONYMOUS_ID, integrations: { Amplitude: { session_id: sessionId } }, event: TelemetryEvents.ApplicationStarted, + context: { + traits: { + telemetry: Telemetry.Disabled, + }, + }, properties: { anonymousId: mockAnonymousId, buildType: AppType.Electron, @@ -150,6 +161,11 @@ describe('AnalyticsService', () => { anonymousId: mockAnonymousId, integrations: { Amplitude: { session_id: sessionId } }, event: TelemetryEvents.ApplicationStarted, + context: { + traits: { + telemetry: Telemetry.Disabled, + }, + }, properties: { anonymousId: mockAnonymousId, buildType: AppType.Electron, @@ -186,6 +202,11 @@ describe('AnalyticsService', () => { anonymousId: mockAnonymousId, integrations: { Amplitude: { session_id: sessionId } }, name: TelemetryEvents.ApplicationStarted, + context: { + traits: { + telemetry: Telemetry.Enabled, + }, + }, properties: { anonymousId: mockAnonymousId, buildType: AppType.Electron, @@ -219,6 +240,11 @@ describe('AnalyticsService', () => { anonymousId: NON_TRACKING_ANONYMOUS_ID, integrations: { Amplitude: { session_id: sessionId } }, name: TelemetryEvents.ApplicationStarted, + context: { + traits: { + telemetry: Telemetry.Disabled, + }, + }, properties: { anonymousId: mockAnonymousId, buildType: AppType.Electron, @@ -241,6 +267,11 @@ describe('AnalyticsService', () => { anonymousId: mockAnonymousId, integrations: { Amplitude: { session_id: sessionId } }, name: TelemetryEvents.ApplicationStarted, + context: { + traits: { + telemetry: Telemetry.Enabled, + }, + }, properties: { anonymousId: mockAnonymousId, buildType: AppType.Electron, diff --git a/redisinsight/api/src/modules/analytics/analytics.service.ts b/redisinsight/api/src/modules/analytics/analytics.service.ts index 26b86ef0ee..fad204fb21 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.ts @@ -24,6 +24,11 @@ export interface ITelemetryInitEvent { appVersion: string; } +export enum Telemetry { + Enabled = 'enabled', + Disabled = 'disabled', +} + @Injectable() export class AnalyticsService { private anonymousId: string = NON_TRACKING_ANONYMOUS_ID; @@ -83,6 +88,11 @@ export class AnalyticsService { anonymousId: !isAnalyticsGranted && nonTracking ? NON_TRACKING_ANONYMOUS_ID : this.anonymousId, integrations: { Amplitude: { session_id: this.sessionId } }, event, + context: { + traits: { + telemetry: isAnalyticsGranted ? Telemetry.Enabled : Telemetry.Disabled, + } + }, properties: { ...eventData, anonymousId: this.anonymousId, @@ -116,6 +126,11 @@ export class AnalyticsService { name: event, anonymousId: !isAnalyticsGranted && nonTracking ? NON_TRACKING_ANONYMOUS_ID : this.anonymousId, integrations: { Amplitude: { session_id: this.sessionId } }, + context: { + traits: { + telemetry: isAnalyticsGranted ? Telemetry.Enabled : Telemetry.Disabled, + } + }, properties: { ...eventData, anonymousId: this.anonymousId, From b7ca17fdad1a4d4fafe48878de5b4b91fbe69c2c Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 7 Nov 2023 00:18:28 +0100 Subject: [PATCH 21/96] #RI-4437 - Rename RedisInsight application --- .circleci/config.yml | 14 ++++---- .circleci/e2e/test.exe.cmd | 2 +- .circleci/redisstack/app-image.repack.sh | 6 ++-- .circleci/redisstack/build_modules.sh | 2 +- .circleci/redisstack/dmg.repack.sh | 8 ++--- electron-builder.json | 2 +- redisinsight/api/config/default.ts | 1 + redisinsight/api/config/production.ts | 8 +++-- redisinsight/api/config/staging.ts | 8 +++-- redisinsight/api/src/config-helper.ts | 20 +++++++++++ redisinsight/api/src/init-helper.ts | 33 ++++++++++++------- .../custom-tutorial.fs.provider.spec.ts | 2 +- .../providers/custom-tutorial.fs.provider.ts | 2 +- .../desktop/src/lib/aboutPanel/aboutPanel.ts | 2 +- tests/e2e/.desktop.env | 2 +- tests/e2e/.env | 2 +- tests/e2e/.gitignore | 3 +- tests/e2e/docker.web.docker-compose.yml | 4 +-- tests/e2e/helpers/conf.ts | 2 +- tests/e2e/local.web.docker-compose.yml | 4 +-- tests/e2e/upload-custom-plugins.sh | 4 +-- 21 files changed, 86 insertions(+), 45 deletions(-) create mode 100644 redisinsight/api/src/config-helper.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index f746df87e9..e273b4300f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1619,28 +1619,28 @@ workflows: - virustotal-url: name: Virus check - AppImage (nightly) - fileName: RedisInsight-v2-linux-x86_64.AppImage + fileName: RedisInsight-linux-x86_64.AppImage - virustotal-url: name: Virus check - deb (nightly) - fileName: RedisInsight-v2-linux-amd64.deb + fileName: RedisInsight-linux-amd64.deb - virustotal-url: name: Virus check - rpm (nightly) - fileName: RedisInsight-v2-linux-x86_64.rpm + fileName: RedisInsight-linux-x86_64.rpm - virustotal-url: name: Virus check - snap (nightly) - fileName: RedisInsight-v2-linux-amd64.snap + fileName: RedisInsight-linux-amd64.snap - virustotal-url: name: Virus check x64 - dmg (nightly) - fileName: RedisInsight-v2-mac-x64.dmg + fileName: RedisInsight-mac-x64.dmg - virustotal-url: name: Virus check arm64 - dmg (nightly) - fileName: RedisInsight-v2-mac-arm64.dmg + fileName: RedisInsight-mac-arm64.dmg - virustotal-url: name: Virus check MAS - pkg (nightly) fileName: RedisInsight-mac-universal-mas.pkg - virustotal-url: name: Virus check - exe (nightly) - fileName: RedisInsight-v2-win-installer.exe + fileName: RedisInsight-win-installer.exe - virustotal-report: name: Virus check report (prod) requires: diff --git a/.circleci/e2e/test.exe.cmd b/.circleci/e2e/test.exe.cmd index 417b54541c..052eeeccd7 100755 --- a/.circleci/e2e/test.exe.cmd +++ b/.circleci/e2e/test.exe.cmd @@ -1,7 +1,7 @@ @echo off set COMMON_URL=%USERPROFILE%/AppData/Local/Programs/redisinsight/resources/app.asar/dist/renderer/index.html -set ELECTRON_PATH=%USERPROFILE%/AppData/Local/Programs/redisinsight/RedisInsight-v2.exe +set ELECTRON_PATH=%USERPROFILE%/AppData/Local/Programs/redisinsight/RedisInsight.exe set OSS_STANDALONE_HOST=%E2E_CLOUD_DATABASE_HOST% set OSS_STANDALONE_PORT=%E2E_CLOUD_DATABASE_PORT% set OSS_STANDALONE_USERNAME=%E2E_CLOUD_DATABASE_USERNAME% diff --git a/.circleci/redisstack/app-image.repack.sh b/.circleci/redisstack/app-image.repack.sh index 7c5821d1b1..438c68c546 100755 --- a/.circleci/redisstack/app-image.repack.sh +++ b/.circleci/redisstack/app-image.repack.sh @@ -3,9 +3,9 @@ set -e ARCH=${ARCH:-x86_64} WORKING_DIRECTORY=$(pwd) -SOURCE_APP=${SOURCE_APP:-"RedisInsight-v2-linux-$ARCH.AppImage"} -APP_FOLDER_NAME="RedisInsight-v2-linux" -TAR_NAME="RedisInsight-v2-app-linux.$ARCH.tar.gz" +SOURCE_APP=${SOURCE_APP:-"RedisInsight-linux-$ARCH.AppImage"} +APP_FOLDER_NAME="RedisInsight-linux" +TAR_NAME="RedisInsight-app-linux.$ARCH.tar.gz" TMP_FOLDER="/tmp/RedisInsight-app-$ARCH" rm -rf "$TMP_FOLDER" diff --git a/.circleci/redisstack/build_modules.sh b/.circleci/redisstack/build_modules.sh index 5eb96b4c7e..a022db6d09 100755 --- a/.circleci/redisstack/build_modules.sh +++ b/.circleci/redisstack/build_modules.sh @@ -5,7 +5,7 @@ PLATFORM=${PLATFORM:-'linux'} ELECTRON_VERSION=$(cat electron/version) ARCH=${ARCH:-'x64'} #FILENAME="RedisInsight-$PLATFORM.$VERSION.$ARCH.zip" -FILENAME="RedisInsight-v2-web-$PLATFORM.$ARCH.tar.gz" +FILENAME="RedisInsight-web-$PLATFORM.$ARCH.tar.gz" # reinstall backend prod dependencies only (optimise space) rm -rf redisinsight/api/node_modules diff --git a/.circleci/redisstack/dmg.repack.sh b/.circleci/redisstack/dmg.repack.sh index 4b4aa12043..e545f4cbe7 100755 --- a/.circleci/redisstack/dmg.repack.sh +++ b/.circleci/redisstack/dmg.repack.sh @@ -3,8 +3,8 @@ set -e ARCH=${ARCH:-x64} WORKING_DIRECTORY=$(pwd) -TAR_NAME="RedisInsight-v2-app-darwin.$ARCH.tar.gz" -APP_FOLDER_NAME="RedisInsight-v2.app" +TAR_NAME="RedisInsight-app-darwin.$ARCH.tar.gz" +APP_FOLDER_NAME="RedisInsight.app" TMP_FOLDER="/tmp/$APP_FOLDER_NAME" rm -rf "$TMP_FOLDER" @@ -12,8 +12,8 @@ rm -rf "$TMP_FOLDER" mkdir -p "$WORKING_DIRECTORY/release/redisstack" mkdir -p "$TMP_FOLDER" -hdiutil attach "./release/RedisInsight-v2-mac-$ARCH.dmg" -cp -a /Volumes/RedisInsight-*/RedisInsight-v2.app "/tmp" +hdiutil attach "./release/RedisInsight-mac-$ARCH.dmg" +cp -a /Volumes/RedisInsight-*/RedisInsight.app "/tmp" cd "/tmp" || exit 1 tar -czvf "$TAR_NAME" "$APP_FOLDER_NAME" cp "$TAR_NAME" "$WORKING_DIRECTORY/release/redisstack/" diff --git a/electron-builder.json b/electron-builder.json index a9aa24d2f9..e376b17280 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -1,5 +1,5 @@ { - "productName": "RedisInsight-v2", + "productName": "RedisInsight", "appId": "org.RedisLabs.RedisInsight-V2", "copyright": "Copyright © 2023 Redis Ltd.", "files": [ diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index a7e39ef64b..2ec0f9dc4c 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -20,6 +20,7 @@ export default { tmpDir: os.tmpdir(), homedir, prevHomedir: homedir, + v2Homedir: homedir, staticDir, defaultsDir, logs: join(homedir, 'logs'), diff --git a/redisinsight/api/config/production.ts b/redisinsight/api/config/production.ts index fbea805930..df93c57720 100644 --- a/redisinsight/api/config/production.ts +++ b/redisinsight/api/config/production.ts @@ -1,15 +1,19 @@ import { join } from 'path'; import * as os from 'os'; +import { getHomedir } from 'src/config-helper'; -const homedir = process.env.APP_FOLDER_ABSOLUTE_PATH - || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2')); +const homedirInit = process.env.APP_FOLDER_ABSOLUTE_PATH + || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight')); const prevHomedir = join(os.homedir(), '.redisinsight-preview'); +const v2Homedir = join(os.homedir(), '.redisinsight-v2'); +const homedir = getHomedir(homedirInit, v2Homedir); export default { dir_path: { homedir, prevHomedir, + v2Homedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), customTutorials: join(homedir, 'custom-tutorials'), diff --git a/redisinsight/api/config/staging.ts b/redisinsight/api/config/staging.ts index e28c0435ae..e82f71be6e 100644 --- a/redisinsight/api/config/staging.ts +++ b/redisinsight/api/config/staging.ts @@ -1,15 +1,19 @@ import { join } from 'path'; import * as os from 'os'; +import { getHomedir } from 'src/config-helper'; -const homedir = process.env.APP_FOLDER_ABSOLUTE_PATH - || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2-stage')); +const homedirInit = process.env.APP_FOLDER_ABSOLUTE_PATH + || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-stage')); const prevHomedir = join(os.homedir(), '.redisinsight-v2.0-stage'); +const v2Homedir = join(os.homedir(), '.redisinsight-v2-stage'); +const homedir = getHomedir(homedirInit, v2Homedir); export default { dir_path: { homedir, prevHomedir, + v2Homedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), customTutorials: join(homedir, 'custom-tutorials'), diff --git a/redisinsight/api/src/config-helper.ts b/redisinsight/api/src/config-helper.ts new file mode 100644 index 0000000000..1da457bce2 --- /dev/null +++ b/redisinsight/api/src/config-helper.ts @@ -0,0 +1,20 @@ + +import * as fs from 'fs-extra'; +import { join } from 'path'; + +/** + * Select homedir based on conditions + * @param homedir + * @param prevHomedir + */ +export const getHomedir = (homedir: string, prevHomedir: string) => { + try { + if ((!fs.pathExistsSync(homedir)) || fs.pathExistsSync(join(homedir, 'plugins'))) { + return homedir; + } + + return prevHomedir; + } catch (error) { + // continue initialization even without migration + } +}; diff --git a/redisinsight/api/src/init-helper.ts b/redisinsight/api/src/init-helper.ts index f250f7e0a8..4b01b914d6 100644 --- a/redisinsight/api/src/init-helper.ts +++ b/redisinsight/api/src/init-helper.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import config from 'src/utils/config'; const PATH_CONFIG = config.get('dir_path'); -const DB_CONFIG = config.get('db'); /** * Copy source if exists @@ -17,21 +16,33 @@ const copySource = async (source, destination) => { } }; +/** + * Conditional data migration to new 'homedir' location + * @param homedir + * @param prevHomedir + */ +const migrateData = async (homedir: string, prevHomedir: string) => { + if (await fs.pathExists(prevHomedir)) { + await fs.ensureDir(homedir); + + await Promise.all([ + 'redisinsight.db', + 'plugins', + ].map((target) => copySource( + join(prevHomedir, target), + join(homedir, target), + ))); + } +}; + /** * Migrate data from previous home folder defined in configs */ export const migrateHomeFolder = async () => { try { - if (!(await fs.pathExists(DB_CONFIG.database)) && await fs.pathExists(PATH_CONFIG.prevHomedir)) { - await fs.ensureDir(PATH_CONFIG.homedir); - - await Promise.all([ - 'redisinsight.db', - 'plugins', - ].map((target) => copySource( - join(PATH_CONFIG.prevHomedir, target), - join(PATH_CONFIG.homedir, target), - ))); + if(!(await fs.pathExists(join(PATH_CONFIG.homedir, 'plugins')))) { + await migrateData(PATH_CONFIG.homedir, PATH_CONFIG.prevHomedir); // from .redisinsight-preview folder + await migrateData(PATH_CONFIG.homedir, PATH_CONFIG.v2Homedir); // from .redisinsight-v2 folder } } catch (e) { // continue initialization even without migration diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts index 99ffb54564..147bcd9931 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts @@ -74,7 +74,7 @@ describe('CustomTutorialFsProvider', () => { prepareTmpFolderSpy.mockRestore(); const result = await service.unzipFromMemoryStoredFile(mockCustomTutorialZipFile); - expect(result).toContain(`${PATH_CONFIG.tmpDir}/RedisInsight-v2/custom-tutorials`); + expect(result).toContain(`${PATH_CONFIG.tmpDir}/RedisInsight/custom-tutorials`); expect(mFs.copy).toHaveBeenCalled(); }); }); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts index 4196c774fd..010f72c367 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts @@ -11,7 +11,7 @@ import ERROR_MESSAGES from 'src/constants/error-messages'; const PATH_CONFIG = config.get('dir_path'); -const TMP_FOLDER = `${PATH_CONFIG.tmpDir}/RedisInsight-v2/custom-tutorials`; +const TMP_FOLDER = `${PATH_CONFIG.tmpDir}/RedisInsight/custom-tutorials`; @Injectable() export class CustomTutorialFsProvider { diff --git a/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts b/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts index 3924b20cb5..922a8c18c2 100644 --- a/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts +++ b/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts @@ -7,7 +7,7 @@ const ICON_PATH = app.isPackaged : path.join(__dirname, '../resources', 'icon.png') export const AboutPanelOptions = { - applicationName: 'RedisInsight-v2', + applicationName: 'RedisInsight', applicationVersion: `${app.getVersion() || '2.36.0'}${ !config.isProduction ? `-dev-${process.getCreationTime()}` : '' }`, diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index a4f603c76c..bc83ba5dd6 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -1,7 +1,7 @@ COMMON_URL=https://localhost:5530 API_URL=https://localhost:5530/api OSS_SENTINEL_PASSWORD=password -APP_FOLDER_NAME=.redisinsight-v2-stage +APP_FOLDER_NAME=.redisinsight-stage OSS_STANDALONE_HOST=localhost OSS_STANDALONE_PORT=8100 diff --git a/tests/e2e/.env b/tests/e2e/.env index 9e354b1b99..89c1342543 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -1,7 +1,7 @@ COMMON_URL=https://app:5000 API_URL=https://app:5000/api OSS_SENTINEL_PASSWORD=password -APP_FOLDER_NAME=.redisinsight-v2 +APP_FOLDER_NAME=.redisinsight NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/notifications.json NOTIFICATION_SYNC_INTERVAL=30000 RI_FEATURES_CONFIG_URL=http://static-server:5551/remote/features-config.json diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore index c61413375a..4929fea8ee 100644 --- a/tests/e2e/.gitignore +++ b/tests/e2e/.gitignore @@ -2,4 +2,5 @@ plugins report results remote -.redisinsight-v2 \ No newline at end of file +.redisinsight +.redisinsight-v2 diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index b139d5c337..91f78971c2 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -10,7 +10,7 @@ services: - ./results:/usr/src/app/results - ./report:/usr/src/app/report - ./plugins:/usr/src/app/plugins - - .redisinsight-v2:/root/.redisinsight-v2 + - .redisinsight:/root/.redisinsight - .ritmp:/tmp - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh @@ -44,7 +44,7 @@ services: env_file: - ./.env volumes: - - .redisinsight-v2:/root/.redisinsight-v2 + - .redisinsight:/root/.redisinsight - .ritmp:/tmp - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh diff --git a/tests/e2e/helpers/conf.ts b/tests/e2e/helpers/conf.ts index f4ee80f89a..6ded09716a 100644 --- a/tests/e2e/helpers/conf.ts +++ b/tests/e2e/helpers/conf.ts @@ -9,7 +9,7 @@ export const commonUrl = process.env.COMMON_URL || 'https://localhost:5000'; export const apiUrl = process.env.API_URL || 'https://localhost:5000/api'; export const workingDirectory = process.env.APP_FOLDER_ABSOLUTE_PATH - || (joinPath(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2')); + || (joinPath(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight')); export const fileDownloadPath = joinPath(os.homedir(), 'Downloads'); const uniqueId = chance.string({ length: 10 }); diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index d92bf1b7c5..ed891616ff 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - ./results:/usr/src/app/results - ./plugins:/usr/src/app/plugins - - .redisinsight-v2:/root/.redisinsight-v2 + - .redisinsight:/root/.redisinsight - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh - ./remote:/root/remote @@ -46,7 +46,7 @@ services: context: ./../../ dockerfile: Dockerfile volumes: - - .redisinsight-v2:/root/.redisinsight-v2 + - .redisinsight:/root/.redisinsight - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh ports: diff --git a/tests/e2e/upload-custom-plugins.sh b/tests/e2e/upload-custom-plugins.sh index f9b9045edc..4de340873a 100755 --- a/tests/e2e/upload-custom-plugins.sh +++ b/tests/e2e/upload-custom-plugins.sh @@ -6,8 +6,8 @@ curl --request GET -sL \ echo "Custom plugins archive was downloaded" -mkdir -p .redisinsight-v2 -unzip -o plugins.zip -d ./.redisinsight-v2/plugins +mkdir -p .redisinsight +unzip -o plugins.zip -d ./.redisinsight/plugins echo "Custom plugins were unarchived" From 0300d175aa3e213a48214da449fb1cb69df7a183 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 7 Nov 2023 12:03:03 +0400 Subject: [PATCH 22/96] #RI-5094 - fix test --- .../api/src/modules/analytics/analytics.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/analytics/analytics.service.spec.ts b/redisinsight/api/src/modules/analytics/analytics.service.spec.ts index e7aa5794c0..18f6d9bf55 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.spec.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.spec.ts @@ -163,7 +163,7 @@ describe('AnalyticsService', () => { event: TelemetryEvents.ApplicationStarted, context: { traits: { - telemetry: Telemetry.Disabled, + telemetry: Telemetry.Enabled, }, }, properties: { From d56e2ad825a1ecbfba9d1ba916b28b0eda881af4 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 7 Nov 2023 11:10:03 +0100 Subject: [PATCH 23/96] #RI-4437 - Rename RedisInsight application --- redisinsight/api/config/production.ts | 2 +- redisinsight/api/config/staging.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/config/production.ts b/redisinsight/api/config/production.ts index df93c57720..247920e760 100644 --- a/redisinsight/api/config/production.ts +++ b/redisinsight/api/config/production.ts @@ -1,6 +1,6 @@ import { join } from 'path'; import * as os from 'os'; -import { getHomedir } from 'src/config-helper'; +import { getHomedir } from '../src/config-helper'; const homedirInit = process.env.APP_FOLDER_ABSOLUTE_PATH || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight')); diff --git a/redisinsight/api/config/staging.ts b/redisinsight/api/config/staging.ts index e82f71be6e..a35ff63aa7 100644 --- a/redisinsight/api/config/staging.ts +++ b/redisinsight/api/config/staging.ts @@ -1,6 +1,6 @@ import { join } from 'path'; import * as os from 'os'; -import { getHomedir } from 'src/config-helper'; +import { getHomedir } from '../src/config-helper'; const homedirInit = process.env.APP_FOLDER_ABSOLUTE_PATH || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-stage')); From 7f419a1676e6f7c2e2142aab8d1496db6fb3f245 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 7 Nov 2023 11:36:25 +0100 Subject: [PATCH 24/96] #RI-4437 - Rename RedisInsight application --- redisinsight/api/src/init-helper.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/redisinsight/api/src/init-helper.ts b/redisinsight/api/src/init-helper.ts index 4b01b914d6..17d7ad719a 100644 --- a/redisinsight/api/src/init-helper.ts +++ b/redisinsight/api/src/init-helper.ts @@ -32,6 +32,9 @@ const migrateData = async (homedir: string, prevHomedir: string) => { join(prevHomedir, target), join(homedir, target), ))); + + // remove previous folder + await fs.rm(prevHomedir, { recursive: true, force: true }); } }; From fcf5b19ab6def760c4558d7b805aa4554459b9bf Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 7 Nov 2023 14:14:17 +0100 Subject: [PATCH 25/96] #RI-4437 - added custom-tutorials --- redisinsight/api/src/init-helper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/redisinsight/api/src/init-helper.ts b/redisinsight/api/src/init-helper.ts index 17d7ad719a..52228ce27e 100644 --- a/redisinsight/api/src/init-helper.ts +++ b/redisinsight/api/src/init-helper.ts @@ -28,6 +28,7 @@ const migrateData = async (homedir: string, prevHomedir: string) => { await Promise.all([ 'redisinsight.db', 'plugins', + 'custom-tutorials', ].map((target) => copySource( join(prevHomedir, target), join(homedir, target), From 096b18928a2e980612383e4a75c574e70fb2b6bd Mon Sep 17 00:00:00 2001 From: Zalenski Egor Date: Tue, 7 Nov 2023 17:19:30 +0300 Subject: [PATCH 26/96] fix macos ci build --- .circleci/redisstack/dmg.repack.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/redisstack/dmg.repack.sh b/.circleci/redisstack/dmg.repack.sh index e545f4cbe7..af4e35828d 100755 --- a/.circleci/redisstack/dmg.repack.sh +++ b/.circleci/redisstack/dmg.repack.sh @@ -13,9 +13,9 @@ mkdir -p "$WORKING_DIRECTORY/release/redisstack" mkdir -p "$TMP_FOLDER" hdiutil attach "./release/RedisInsight-mac-$ARCH.dmg" -cp -a /Volumes/RedisInsight-*/RedisInsight.app "/tmp" +cp -a /Volumes/RedisInsight/RedisInsight.app "/tmp" cd "/tmp" || exit 1 tar -czvf "$TAR_NAME" "$APP_FOLDER_NAME" cp "$TAR_NAME" "$WORKING_DIRECTORY/release/redisstack/" cd "$WORKING_DIRECTORY" || exit 1 -hdiutil unmount /Volumes/RedisInsight-*/ +hdiutil unmount /Volumes/RedisInsight/ From 4c3c9185192117130b97fd403d3f6b91c0a477c3 Mon Sep 17 00:00:00 2001 From: Zalenski Egor Date: Tue, 7 Nov 2023 16:10:35 +0100 Subject: [PATCH 27/96] #RI-5129 - macOS build failures on CI in build branch --- .circleci/redisstack/dmg.repack.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/redisstack/dmg.repack.sh b/.circleci/redisstack/dmg.repack.sh index af4e35828d..6b5f855876 100755 --- a/.circleci/redisstack/dmg.repack.sh +++ b/.circleci/redisstack/dmg.repack.sh @@ -13,9 +13,9 @@ mkdir -p "$WORKING_DIRECTORY/release/redisstack" mkdir -p "$TMP_FOLDER" hdiutil attach "./release/RedisInsight-mac-$ARCH.dmg" -cp -a /Volumes/RedisInsight/RedisInsight.app "/tmp" +cp -a /Volumes/RedisInsight*/RedisInsight.app "/tmp" cd "/tmp" || exit 1 tar -czvf "$TAR_NAME" "$APP_FOLDER_NAME" cp "$TAR_NAME" "$WORKING_DIRECTORY/release/redisstack/" cd "$WORKING_DIRECTORY" || exit 1 -hdiutil unmount /Volumes/RedisInsight/ +hdiutil unmount /Volumes/RedisInsight*/ From b3397821f4b89324c1989ca0513f7573ea663c95 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 9 Nov 2023 12:10:23 +0400 Subject: [PATCH 28/96] #RI-5009 - resolve comments --- redisinsight/ui/src/pages/home/HomePage.tsx | 8 +- .../CloudConnectionFormWrapper.spec.tsx | 0 .../CloudConnectionFormWrapper.tsx | 2 +- .../CloudConnectionForm.spec.tsx | 0 .../CloudConnectionForm.tsx | 0 .../cloud-connection-form}/index.ts | 0 .../index.ts | 0 .../styles.module.scss | 0 .../ClusterConnectionFormWrapper.spec.tsx | 5 +- .../ClusterConnectionFormWrapper.tsx | 2 +- .../ClusterConnectionForm.spec.tsx | 0 .../ClusterConnectionForm.tsx | 0 .../cluster-connection-form}/index.ts | 0 .../index.ts | 0 .../styles.module.scss | 0 .../types.ts | 0 .../DatabaseAlias.spec.tsx | 0 .../DatabaseAlias.tsx | 95 +- .../index.ts | 0 .../styles.module.scss | 0 .../DatabasePanel.spec.tsx | 0 .../DatabasePanel.tsx | 16 +- .../index.ts | 0 .../InstanceConnections.spec.tsx | 0 .../InstanceConnections.tsx | 0 .../instance-connections/index.ts | 3 + .../styles.module.scss | 0 .../DatabasesListWrapper.spec.tsx | 4 +- .../DatabasesListWrapper.tsx | 2 +- .../databases-list}/DatabasesList.spec.tsx | 0 .../databases-list}/DatabasesList.tsx | 0 .../components/action-bar/ActionBar.spec.tsx | 0 .../components/action-bar/ActionBar.tsx | 0 .../components/action-bar/styles.module.scss | 0 .../delete-action/DeleteAction.spec.tsx | 0 .../components/delete-action/DeleteAction.tsx | 0 .../export-action/ExportAction.spec.tsx | 0 .../components/export-action/ExportAction.tsx | 0 .../databases-list}/components/index.ts | 0 .../components/styles.module.scss | 0 .../databases-list}/index.ts | 0 .../index.ts | 0 .../styles.module.scss | 0 .../HelpLinksMenu.tsx | 0 .../HelpLinskMenu.spec.tsx | 0 .../index.ts | 0 .../styles.module.scss | 0 .../HomeHeader.spec.tsx | 0 .../HomeHeader.tsx | 8 +- .../{HomeHeader => home-header}/index.ts | 0 .../styles.module.scss | 0 .../ManualConnectionWrapper.spec.tsx | 1247 +++++++++++++++++ .../ManualConnectionWrapper.tsx | 130 +- .../index.ts | 0 .../ManualConnectionForm.tsx | 91 +- .../ManualConnectionFrom.spec.tsx | 24 +- .../manual-connection-form}/index.ts | 0 .../SearchDatabasesList.spec.tsx | 0 .../SearchDatabasesList.tsx | 0 .../index.ts | 0 .../styles.module.scss | 0 .../SentinelConnectionWrapper.tsx | 5 +- .../index.ts | 0 .../SentinelConnectionForm.spec.tsx | 0 .../SentinelConnectionForm.tsx | 5 +- .../sentinel-connection-form}/index.ts | 0 .../WelcomeComponent.spec.tsx | 0 .../WelcomeComponent.tsx | 0 .../index.ts | 0 .../styles.module.scss | 0 .../ui/src/pages/home/constants/form.ts | 3 +- redisinsight/ui/src/pages/home/utils/form.tsx | 3 +- .../edit-connection/EditConnection.tsx | 2 +- 73 files changed, 1415 insertions(+), 240 deletions(-) rename redisinsight/ui/src/pages/home/components/{CloudConnection => cloud-connection}/CloudConnectionFormWrapper.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{CloudConnection => cloud-connection}/CloudConnectionFormWrapper.tsx (96%) rename redisinsight/ui/src/pages/home/components/{CloudConnection/CloudConnectionForm => cloud-connection/cloud-connection-form}/CloudConnectionForm.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{CloudConnection/CloudConnectionForm => cloud-connection/cloud-connection-form}/CloudConnectionForm.tsx (100%) rename redisinsight/ui/src/pages/home/components/{CloudConnection/CloudConnectionForm => cloud-connection/cloud-connection-form}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{CloudConnection => cloud-connection}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{CloudConnection => cloud-connection}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection => cluster-connection}/ClusterConnectionFormWrapper.spec.tsx (91%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection => cluster-connection}/ClusterConnectionFormWrapper.tsx (95%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection/ClusterConnectionForm => cluster-connection/cluster-connection-form}/ClusterConnectionForm.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection/ClusterConnectionForm => cluster-connection/cluster-connection-form}/ClusterConnectionForm.tsx (100%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection/ClusterConnectionForm => cluster-connection/cluster-connection-form}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection => cluster-connection}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection => cluster-connection}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{ClusterConnection => cluster-connection}/types.ts (100%) rename redisinsight/ui/src/pages/home/components/{DatabaseAlias => database-alias}/DatabaseAlias.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabaseAlias => database-alias}/DatabaseAlias.tsx (74%) rename redisinsight/ui/src/pages/home/components/{DatabaseAlias => database-alias}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{DatabaseAlias => database-alias}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{DatabasePanel => database-panel}/DatabasePanel.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasePanel => database-panel}/DatabasePanel.tsx (95%) rename redisinsight/ui/src/pages/home/components/{DatabasePanel => database-panel}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{DatabasePanel/InstanceConnections => database-panel/instance-connections}/InstanceConnections.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasePanel/InstanceConnections => database-panel/instance-connections}/InstanceConnections.tsx (100%) create mode 100644 redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts rename redisinsight/ui/src/pages/home/components/{DatabasePanel => database-panel}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent => databases-list-component}/DatabasesListWrapper.spec.tsx (97%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent => databases-list-component}/DatabasesListWrapper.tsx (99%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/DatabasesList.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/DatabasesList.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/action-bar/ActionBar.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/action-bar/ActionBar.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/action-bar/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/delete-action/DeleteAction.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/delete-action/DeleteAction.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/export-action/ExportAction.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/export-action/ExportAction.tsx (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/components/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent/DatabasesList => databases-list-component/databases-list}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent => databases-list-component}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{DatabasesListComponent => databases-list-component}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{HelpLinksMenu => help-links-menu}/HelpLinksMenu.tsx (100%) rename redisinsight/ui/src/pages/home/components/{HelpLinksMenu => help-links-menu}/HelpLinskMenu.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{HelpLinksMenu => help-links-menu}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{HelpLinksMenu => help-links-menu}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{HomeHeader => home-header}/HomeHeader.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{HomeHeader => home-header}/HomeHeader.tsx (95%) rename redisinsight/ui/src/pages/home/components/{HomeHeader => home-header}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{HomeHeader => home-header}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx rename redisinsight/ui/src/pages/home/components/{ManualConnection => manual-connection}/ManualConnectionWrapper.tsx (71%) rename redisinsight/ui/src/pages/home/components/{ManualConnection => manual-connection}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{ManualConnection/ManualConnectionForm => manual-connection/manual-connection-form}/ManualConnectionForm.tsx (87%) rename redisinsight/ui/src/pages/home/components/{ManualConnection/ManualConnectionForm => manual-connection/manual-connection-form}/ManualConnectionFrom.spec.tsx (99%) rename redisinsight/ui/src/pages/home/components/{ManualConnection/ManualConnectionForm => manual-connection/manual-connection-form}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{SearchDatabasesList => search-databases-list}/SearchDatabasesList.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{SearchDatabasesList => search-databases-list}/SearchDatabasesList.tsx (100%) rename redisinsight/ui/src/pages/home/components/{SearchDatabasesList => search-databases-list}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{SearchDatabasesList => search-databases-list}/styles.module.scss (100%) rename redisinsight/ui/src/pages/home/components/{SentinelConnection => sentinel-connection}/SentinelConnectionWrapper.tsx (94%) rename redisinsight/ui/src/pages/home/components/{SentinelConnection => sentinel-connection}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{SentinelConnection/SentinelConnectionForm => sentinel-connection/sentinel-connection-form}/SentinelConnectionForm.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{SentinelConnection/SentinelConnectionForm => sentinel-connection/sentinel-connection-form}/SentinelConnectionForm.tsx (98%) rename redisinsight/ui/src/pages/home/components/{SentinelConnection/SentinelConnectionForm => sentinel-connection/sentinel-connection-form}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{WelcomeComponent => welcome-component}/WelcomeComponent.spec.tsx (100%) rename redisinsight/ui/src/pages/home/components/{WelcomeComponent => welcome-component}/WelcomeComponent.tsx (100%) rename redisinsight/ui/src/pages/home/components/{WelcomeComponent => welcome-component}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{WelcomeComponent => welcome-component}/styles.module.scss (100%) diff --git a/redisinsight/ui/src/pages/home/HomePage.tsx b/redisinsight/ui/src/pages/home/HomePage.tsx index 3e05265023..10853c7b63 100644 --- a/redisinsight/ui/src/pages/home/HomePage.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.tsx @@ -2,7 +2,7 @@ import { EuiPage, EuiPageBody, EuiResizableContainer, EuiResizeObserver } from ' import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' -import DatabasePanel from 'redisinsight/ui/src/pages/home/components/DatabasePanel' +import DatabasePanel from 'uiSrc/pages/home/components/database-panel' import { clusterSelector, resetDataRedisCluster, resetInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' import { Nullable, setTitle } from 'uiSrc/utils' import { PageHeader } from 'uiSrc/components' @@ -27,9 +27,9 @@ import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPag import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' import { AddDbType } from 'uiSrc/pages/home/constants' -import DatabasesList from './components/DatabasesListComponent' -import WelcomeComponent from './components/WelcomeComponent' -import HomeHeader from './components/HomeHeader' +import DatabasesList from './components/databases-list-component' +import WelcomeComponent from './components/welcome-component' +import HomeHeader from './components/home-header' import './styles.scss' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.spec.tsx rename to redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx similarity index 96% rename from redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx rename to redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx index 9da568212c..71f07cd700 100644 --- a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx @@ -8,7 +8,7 @@ import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import CloudConnectionForm from './CloudConnectionForm' +import CloudConnectionForm from './cloud-connection-form' export interface Props { width: number diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/CloudConnectionForm.spec.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/CloudConnectionForm.spec.tsx rename to redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/CloudConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/CloudConnectionForm.tsx rename to redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.tsx diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/index.ts rename to redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/index.ts diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/index.ts b/redisinsight/ui/src/pages/home/components/cloud-connection/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/CloudConnection/index.ts rename to redisinsight/ui/src/pages/home/components/cloud-connection/index.ts diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/styles.module.scss b/redisinsight/ui/src/pages/home/components/cloud-connection/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/CloudConnection/styles.module.scss rename to redisinsight/ui/src/pages/home/components/cloud-connection/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx similarity index 91% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.spec.tsx rename to redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx index a0569dca26..341145e689 100644 --- a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx @@ -1,14 +1,15 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import ClusterConnectionForm, { Props as ClusterConnectionFormProps } from + 'uiSrc/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm' import ClusterConnectionFormWrapper, { Props, } from './ClusterConnectionFormWrapper' -import ClusterConnectionForm, { Props as ClusterConnectionFormProps } from './ClusterConnectionForm/ClusterConnectionForm' const mockedProps = mock() -jest.mock('./ClusterConnectionForm/ClusterConnectionForm', () => ({ +jest.mock('./cluster-connection-form/cluster-connection-form', () => ({ __esModule: true, namedExport: jest.fn(), default: jest.fn(), diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx similarity index 95% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx rename to redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx index 5d72654536..863c6ac500 100644 --- a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx @@ -13,7 +13,7 @@ import { ICredentialsRedisCluster, InstanceType } from 'uiSrc/slices/interfaces' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { autoFillFormDetails } from 'uiSrc/pages/home/utils' -import ClusterConnectionForm from './ClusterConnectionForm/ClusterConnectionForm' +import ClusterConnectionForm from 'uiSrc/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm' export interface Props { width: number; diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/ClusterConnectionForm.spec.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/ClusterConnectionForm.spec.tsx rename to redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/ClusterConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/ClusterConnectionForm.tsx rename to redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.tsx diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/index.ts rename to redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/index.ts diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/index.ts b/redisinsight/ui/src/pages/home/components/cluster-connection/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/index.ts rename to redisinsight/ui/src/pages/home/components/cluster-connection/index.ts diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/styles.module.scss b/redisinsight/ui/src/pages/home/components/cluster-connection/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/styles.module.scss rename to redisinsight/ui/src/pages/home/components/cluster-connection/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/types.ts b/redisinsight/ui/src/pages/home/components/cluster-connection/types.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/ClusterConnection/types.ts rename to redisinsight/ui/src/pages/home/components/cluster-connection/types.ts diff --git a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.spec.tsx b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.spec.tsx rename to redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx similarity index 74% rename from redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx rename to redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx index 24faa0fe03..52a726911f 100644 --- a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx +++ b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx @@ -11,12 +11,15 @@ import { EuiToolTip, } from '@elastic/eui' import cx from 'classnames' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { toNumber } from 'lodash' +import { useHistory } from 'react-router' + +import { AdditionalRedisModule } from 'apiSrc/modules/database/models/additional.redis.module' import { BuildType } from 'uiSrc/constants/env' import { appInfoSelector } from 'uiSrc/slices/app/info' import { Nullable, getDbIndex } from 'uiSrc/utils' -import { Theme } from 'uiSrc/constants' +import { PageNames, Pages, Theme } from 'uiSrc/constants' import { ThemeContext } from 'uiSrc/contexts/themeContext' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' import RediStackDarkMin from 'uiSrc/assets/img/modules/redistack/RediStackDark-min.svg' @@ -24,29 +27,52 @@ import RediStackLightMin from 'uiSrc/assets/img/modules/redistack/RediStackLight import RediStackLightLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoLight.svg' import RediStackDarkLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoDark.svg' +import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { + changeInstanceAliasAction, + checkConnectToInstanceAction, + setConnectedInstanceId +} from 'uiSrc/slices/instances/instances' +import { resetKeys } from 'uiSrc/slices/browser/keys' +import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' import styles from './styles.module.scss' export interface Props { alias: string database?: Nullable - onOpen: () => void - onClone: () => void - onCloneBack: () => void isLoading: boolean - onApplyChanges: (value: string, onSuccess?: () => void, onFail?: () => void) => void + onAliasEdited?: (value: string) => void isRediStack?: boolean isCloneMode: boolean + id?: string + provider?: string + setIsCloneMode: (value: boolean) => void + modules: AdditionalRedisModule[] } const DatabaseAlias = (props: Props) => { - const { alias, database, onOpen, onClone, onCloneBack, onApplyChanges, isLoading, isRediStack, isCloneMode } = props + const { + alias, + database, + id, + provider, + onAliasEdited, + isLoading, + isRediStack, + isCloneMode, + setIsCloneMode, + modules, + } = props const { server } = useSelector(appInfoSelector) + const { contextInstanceId, lastPage } = useSelector(appContextSelector) const [isEditing, setIsEditing] = useState(false) const [value, setValue] = useState(alias) const { theme } = useContext(ThemeContext) + const history = useHistory() + const dispatch = useDispatch() useEffect(() => { setValue(alias) @@ -60,21 +86,68 @@ const DatabaseAlias = (props: Props) => { isEditing && setValue(value) } + const connectToInstance = () => { + if (contextInstanceId && contextInstanceId !== id) { + dispatch(resetKeys()) + dispatch(setAppContextInitialState()) + } + dispatch(setConnectedInstanceId(id ?? '')) + + if (lastPage === PageNames.workbench && contextInstanceId === id) { + history.push(Pages.workbench(id)) + return + } + history.push(Pages.browser(id ?? '')) + } + const handleOpen = (event: any) => { event.stopPropagation() event.preventDefault() - onOpen() + const modulesSummary = getRedisModulesSummary(modules) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_OPEN_DATABASE_BUTTON_CLICKED, + eventData: { + databaseId: id, + provider, + ...modulesSummary, + } + }) + dispatch(checkConnectToInstanceAction(id, connectToInstance)) + // onOpen() } const handleClone = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() - onClone() + setIsCloneMode(true) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_REQUESTED, + eventData: { + databaseId: id + } + }) } const handleApplyChanges = () => { setIsEditing(false) - onApplyChanges(value, () => {}, () => setValue(alias)) + dispatch(changeInstanceAliasAction( + id, + value, + () => { + onAliasEdited?.(value) + }, + () => setValue(alias) + )) + } + + const handleCloneBack = () => { + setIsCloneMode(false) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CANCELLED, + eventData: { + databaseId: id + } + }) } const handleDeclineChanges = (event?: React.MouseEvent) => { @@ -89,7 +162,7 @@ const DatabaseAlias = (props: Props) => { {isCloneMode && ( { } }, [action, dbConnection]) + useEffect(() => { + if (editMode) { + setConnectionType(AddDbType.manual) + } + }, [editMode]) + useEffect(() => // ComponentWillUnmount () => { diff --git a/redisinsight/ui/src/pages/home/components/DatabasePanel/index.ts b/redisinsight/ui/src/pages/home/components/database-panel/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasePanel/index.ts rename to redisinsight/ui/src/pages/home/components/database-panel/index.ts diff --git a/redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.spec.tsx b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.spec.tsx rename to redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.tsx b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasePanel/InstanceConnections/InstanceConnections.tsx rename to redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.tsx diff --git a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts new file mode 100644 index 0000000000..162026205d --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts @@ -0,0 +1,3 @@ +import InstanceConnections from './InstanceConnections' + +export default InstanceConnections diff --git a/redisinsight/ui/src/pages/home/components/DatabasePanel/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-panel/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasePanel/styles.module.scss rename to redisinsight/ui/src/pages/home/components/database-panel/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx similarity index 97% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx index ea442a4d66..3c2a57ab72 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx @@ -11,11 +11,11 @@ import { RootState, store } from 'uiSrc/slices/store' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' import DatabasesListWrapper, { Props } from './DatabasesListWrapper' -import DatabasesList, { Props as DatabasesListProps } from './DatabasesList' +import DatabasesList, { Props as DatabasesListProps } from './databases-list' const mockedProps = mock() -jest.mock('./DatabasesList/DatabasesList', () => ({ +jest.mock('./databases-list/databases-list', () => ({ __esModule: true, namedExport: jest.fn(), default: jest.fn(), diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.tsx similarity index 99% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.tsx index 54c6ca183b..49c8c33263 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.tsx @@ -40,7 +40,7 @@ import RediStackLightLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLog import RediStackDarkLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoDark.svg' import { ReactComponent as CloudLinkIcon } from 'uiSrc/assets/img/oauth/cloud_link.svg' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' -import DatabasesList from './DatabasesList' +import DatabasesList from './databases-list' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.spec.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/action-bar/ActionBar.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/action-bar/ActionBar.spec.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/action-bar/ActionBar.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/action-bar/ActionBar.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/action-bar/styles.module.scss b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/action-bar/styles.module.scss rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/delete-action/DeleteAction.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/delete-action/DeleteAction.spec.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/delete-action/DeleteAction.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/delete-action/DeleteAction.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/export-action/ExportAction.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/export-action/ExportAction.spec.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/export-action/ExportAction.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/export-action/ExportAction.tsx rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.tsx diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/index.ts b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/index.ts rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/index.ts diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/styles.module.scss b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/components/styles.module.scss rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/index.ts b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/index.ts rename to redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/index.ts diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/index.ts b/redisinsight/ui/src/pages/home/components/databases-list-component/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/index.ts rename to redisinsight/ui/src/pages/home/components/databases-list-component/index.ts diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss b/redisinsight/ui/src/pages/home/components/databases-list-component/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss rename to redisinsight/ui/src/pages/home/components/databases-list-component/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx b/redisinsight/ui/src/pages/home/components/help-links-menu/HelpLinksMenu.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx rename to redisinsight/ui/src/pages/home/components/help-links-menu/HelpLinksMenu.tsx diff --git a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinskMenu.spec.tsx b/redisinsight/ui/src/pages/home/components/help-links-menu/HelpLinskMenu.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinskMenu.spec.tsx rename to redisinsight/ui/src/pages/home/components/help-links-menu/HelpLinskMenu.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/index.ts b/redisinsight/ui/src/pages/home/components/help-links-menu/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/HelpLinksMenu/index.ts rename to redisinsight/ui/src/pages/home/components/help-links-menu/index.ts diff --git a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss b/redisinsight/ui/src/pages/home/components/help-links-menu/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss rename to redisinsight/ui/src/pages/home/components/help-links-menu/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx b/redisinsight/ui/src/pages/home/components/home-header/HomeHeader.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx rename to redisinsight/ui/src/pages/home/components/home-header/HomeHeader.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx b/redisinsight/ui/src/pages/home/components/home-header/HomeHeader.tsx similarity index 95% rename from redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx rename to redisinsight/ui/src/pages/home/components/home-header/HomeHeader.tsx index 8c3f5ea526..cdec9f2e31 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/home-header/HomeHeader.tsx @@ -11,9 +11,9 @@ import { import { isEmpty } from 'lodash' import { useSelector } from 'react-redux' import cx from 'classnames' -import { FeatureFlagComponent, ImportDatabasesDialog, OAuthSsoHandlerDialog } from 'uiSrc/components' +import { ImportDatabasesDialog, OAuthSsoHandlerDialog } from 'uiSrc/components' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import HelpLinksMenu from 'uiSrc/pages/home/components/HelpLinksMenu' +import HelpLinksMenu from 'uiSrc/pages/home/components/help-links-menu' import PromoLink from 'uiSrc/components/promo-link/PromoLink' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' @@ -22,11 +22,9 @@ import { getPathToResource } from 'uiSrc/services/resourcesService' import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' import { instancesSelector } from 'uiSrc/slices/instances/instances' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' -import { FeatureFlags } from 'uiSrc/constants' -import { ReactComponent as ConfettiIcon } from 'uiSrc/assets/img/oauth/confetti.svg' import { getContentByFeature } from 'uiSrc/utils/content' import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' -import SearchDatabasesList from '../SearchDatabasesList' +import SearchDatabasesList from '../search-databases-list' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/index.ts b/redisinsight/ui/src/pages/home/components/home-header/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/HomeHeader/index.ts rename to redisinsight/ui/src/pages/home/components/home-header/index.ts diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss b/redisinsight/ui/src/pages/home/components/home-header/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss rename to redisinsight/ui/src/pages/home/components/home-header/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx new file mode 100644 index 0000000000..afd4dbcee5 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx @@ -0,0 +1,1247 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { BuildType } from 'uiSrc/constants/env' +import { appRedirectionSelector } from 'uiSrc/slices/app/url-handling' +import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' +import { ADD_NEW_CA_CERT, SshPassType } from 'uiSrc/pages/home/constants' +import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' + +import ManualConnectionWrapper, { Props } from './ManualConnectionWrapper' + +const BTN_SUBMIT = 'btn-submit' +const NEW_CA_CERT = 'new-ca-cert' +const QA_CA_CERT = 'qa-ca-cert' +const RADIO_BTN_PRIVATE_KEY = '[data-test-subj="radio-btn-privateKey"] label' +const BTN_TEST_CONNECTION = 'btn-test-connection' + +const mockedProps = mock() +const mockedDbConnectionInfo = mock() + +const formFields = { + ...instance(mockedDbConnectionInfo), + host: 'localhost', + port: '6379', + name: 'lala', +} + +// jest.mock('uiSrc/slices/instances/instances', () => ({ +// checkConnectToInstanceAction: () => jest.fn, +// resetInstanceUpdateAction: () => jest.fn, +// changeInstanceAliasAction: () => jest.fn, +// setConnectedInstanceId: jest.fn, +// })) +// +// jest.mock('uiSrc/slices/app/url-handling', () => ({ +// ...jest.requireActual('uiSrc/slices/app/url-handling'), +// appRedirectionSelector: jest.fn().mockReturnValue(() => ({ action: null })), +// })) + +describe('InstanceForm', () => { + it('should render', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + // it('should render with ConnectionType.Sentinel', () => { + // expect( + // render( + // + // ) + // ).toBeTruthy() + // }) + // + // it('should render with ConnectionType.Cluster', () => { + // expect( + // render( + // + // ) + // ).toBeTruthy() + // }) + // + // it('should render tooltip with nodes', () => { + // expect( + // render( + // + // ) + // ).toBeTruthy() + // }) + // + it('should render DatabaseForm', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + // + // it('should change sentinelMasterUsername input properly', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // + // render( + //
+ // + //
+ // ) + // + // await act(() => { + // fireEvent.change(screen.getByTestId('sentinel-mater-username'), { + // target: { value: 'user' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // sentinelMasterUsername: 'user', + // }) + // ) + // + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // sentinelMasterUsername: 'user', + // }) + // ) + // }) + // + // it('should change port input properly', async () => { + // const handleSubmit = jest.fn() + // render( + //
+ // + //
+ // ) + // + // await act(() => { + // fireEvent.change(screen.getByTestId('port'), { + // target: { value: '123' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // port: '123', + // }) + // ) + // }) + // + // it('should change tls checkbox', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('tls')) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // tls: ['on'], + // }) + // ) + // + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // tls: ['on'], + // }) + // ) + // }) + // + // it('should change Database Index checkbox', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('showDb')) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // showDb: ['on'], + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // showDb: ['on'], + // }) + // ) + // }) + // + // it('should change db checkbox and value', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('showDb')) + // }) + // + // await act(() => { + // fireEvent.change(screen.getByTestId('db'), { + // target: { value: '12' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // showDb: ['on'], + // db: '12' + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // showDb: ['on'], + // db: '12' + // }) + // ) + // }) + // + // it('should change "Use SNI" with prepopulated with host', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('sni')) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // sni: ['on'], + // servername: formFields.host + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // sni: ['on'], + // servername: formFields.host + // }) + // ) + // }) + // + // it('should change "Use SNI"', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('sni')) + // }) + // + // await act(() => { + // fireEvent.change(screen.getByTestId('sni-servername'), { + // target: { value: '12' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // sni: ['on'], + // servername: '12' + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // sni: ['on'], + // servername: '12' + // }) + // ) + // }) + // + // it('should change "Verify TLS Certificate"', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('verify-tls-cert')) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // verifyServerTlsCert: ['on'], + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // verifyServerTlsCert: ['on'], + // }) + // ) + // }) + // + // it('should select value from "CA Certificate"', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // const { queryByText } = render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('select-ca-cert')) + // }) + // await act(() => { + // fireEvent.click(queryByText('Add new CA certificate') || document) + // }) + // + // expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId(NEW_CA_CERT), { + // target: { value: '123' }, + // }) + // }) + // + // expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId(QA_CA_CERT), { + // target: { value: '321' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // selectedCaCertName: ADD_NEW_CA_CERT, + // newCaCertName: '321', + // newCaCert: '123', + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // selectedCaCertName: ADD_NEW_CA_CERT, + // newCaCertName: '321', + // newCaCert: '123', + // }) + // ) + // }) + // + // it('should render fields for add new CA and change them properly', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // + // expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId(QA_CA_CERT), { + // target: { value: '321' }, + // }) + // }) + // + // expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId(NEW_CA_CERT), { + // target: { value: '123' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // newCaCert: '123', + // newCaCertName: '321', + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // newCaCert: '123', + // newCaCertName: '321', + // }) + // ) + // }) + // + // it('should change "Requires TLS Client Authentication"', async () => { + // const handleSubmit = jest.fn() + // const handleTestConnection = jest.fn() + // render( + //
+ // + //
+ // ) + // await act(() => { + // fireEvent.click(screen.getByTestId('tls-required-checkbox')) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) + // await act(() => { + // fireEvent.click(testConnectionBtn) + // }) + // expect(handleTestConnection).toBeCalledWith( + // expect.objectContaining({ + // tlsClientAuthRequired: ['on'], + // }) + // ) + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // tlsClientAuthRequired: ['on'], + // }) + // ) + // }) + // + // it('should render fields for add new CA with required tls auth and change them properly', async () => { + // const handleSubmit = jest.fn() + // const { container } = render( + //
+ // + //
+ // ) + // + // expect(screen.getByTestId('select-cert')).toBeInTheDocument() + // + // await act(() => { + // fireEvent.click(screen.getByTestId('select-cert')) + // }) + // + // await act(() => { + // fireEvent.click( + // container.querySelectorAll('.euiContextMenuItem__text')[0] || document + // ) + // }) + // + // expect(screen.getByTestId('new-tsl-cert-pair-name')).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId('new-tsl-cert-pair-name'), { + // target: { value: '123' }, + // }) + // }) + // + // expect(screen.getByTestId('new-tls-client-cert')).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId('new-tls-client-cert'), { + // target: { value: '321' }, + // }) + // }) + // + // expect(screen.getByTestId('new-tls-client-cert-key')).toBeInTheDocument() + // await act(() => { + // fireEvent.change(screen.getByTestId('new-tls-client-cert-key'), { + // target: { value: '231' }, + // }) + // }) + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // + // await act(() => { + // fireEvent.click(submitBtn) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // newTlsClientCert: '321', + // newTlsCertPairName: '123', + // newTlsClientKey: '231', + // }) + // ) + // }) + // + // it('should render clone mode btn', () => { + // render( + // + // ) + // expect(screen.getByTestId('clone-db-btn')).toBeTruthy() + // }) + // + // describe('should render proper fields with Clone mode', () => { + // it('should render proper fields for standalone db', () => { + // render( + // + // ) + // const fieldsTestIds = ['host', 'port', 'username', 'password', 'showDb', 'tls'] + // fieldsTestIds.forEach((id) => { + // expect(screen.getByTestId(id)).toBeTruthy() + // }) + // }) + // + // it('should render proper fields for sentinel db', () => { + // render( + // + // ) + // const fieldsTestIds = [ + // 'name', + // 'primary-group', + // 'sentinel-mater-username', + // 'sentinel-master-password', + // 'host', + // 'port', + // 'username', + // 'password', + // 'showDb', + // 'tls' + // ] + // fieldsTestIds.forEach((id) => { + // expect(screen.getByTestId(id)).toBeTruthy() + // }) + // }) + // + // it('should render selected logical database with proper db index', () => { + // render( + // + // ) + // // expect(screen.getByTestId('showDb')).toBeChecked() + // expect(screen.getByTestId('db')).toHaveValue('5') + // }) + // + // it('should render proper database alias', () => { + // render( + // + // ) + // expect(screen.getByTestId('db-alias')).toHaveTextContent('Clone ') + // }) + // + // it('should render proper default values for standalone', () => { + // render( + // + // ) + // expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') + // expect(screen.getByTestId('port')).toHaveValue('6379') + // expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') + // }) + // }) + // + // it('should change Use SSH checkbox', async () => { + // const handleSubmit = jest.fn() + // render( + //
+ // + //
+ // ) + // + // fireEvent.click(screen.getByTestId('use-ssh')) + // + // expect(screen.getByTestId('use-ssh')).toBeChecked() + // }) + // + // it('should not render Use SSH checkbox for redis stack buidlType', async () => { + // const handleSubmit = jest.fn() + // render( + //
+ // + //
+ // ) + // + // expect(screen.queryByTestId('use-ssh')).not.toBeInTheDocument() + // }) + // + // it('should change Use SSH checkbox and show proper fields for password radio', async () => { + // const handleSubmit = jest.fn() + // render( + //
+ // + //
+ // ) + // + // act(() => { + // fireEvent.click(screen.getByTestId('use-ssh')) + // }) + // + // expect(screen.getByTestId('sshHost')).toBeInTheDocument() + // expect(screen.getByTestId('sshPort')).toBeInTheDocument() + // expect(screen.getByTestId('sshPort')).toHaveValue('22') + // expect(screen.getByTestId('sshPassword')).toBeInTheDocument() + // expect(screen.queryByTestId('sshPrivateKey')).not.toBeInTheDocument() + // expect(screen.queryByTestId('sshPassphrase')).not.toBeInTheDocument() + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // expect(submitBtn).toBeDisabled() + // }) + // + // it('should change Use SSH checkbox and show proper fields for passphrase radio', async () => { + // const handleSubmit = jest.fn() + // const { container } = render( + //
+ // + //
+ // ) + // + // await act(() => { + // fireEvent.click(screen.getByTestId('use-ssh')) + // fireEvent.click( + // container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + // ) + // }) + // + // expect(screen.getByTestId('sshHost')).toBeInTheDocument() + // expect(screen.getByTestId('sshPort')).toBeInTheDocument() + // expect(screen.getByTestId('sshPort')).toHaveValue('22') + // expect(screen.queryByTestId('sshPassword')).not.toBeInTheDocument() + // expect(screen.getByTestId('sshPrivateKey')).toBeInTheDocument() + // expect(screen.getByTestId('sshPassphrase')).toBeInTheDocument() + // + // const submitBtn = screen.getByTestId(BTN_SUBMIT) + // expect(submitBtn).toBeDisabled() + // }) + // + // it('should be proper validation for ssh via ssh password', async () => { + // const handleSubmit = jest.fn() + // render( + //
+ // + //
+ // ) + // + // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + // + // await act(() => { + // fireEvent.click(screen.getByTestId('use-ssh')) + // }) + // + // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + // + // await act(() => { + // fireEvent.change( + // screen.getByTestId('sshHost'), + // { target: { value: 'localhost' } } + // ) + // }) + // + // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + // + // await act(() => { + // fireEvent.change( + // screen.getByTestId('sshUsername'), + // { target: { value: 'username' } } + // ) + // }) + // + // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + // }) + // + // it('should be proper validation for ssh via ssh passphrase', async () => { + // const handleSubmit = jest.fn() + // const { container } = render( + //
+ // + //
+ // ) + // + // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + // + // await act(() => { + // fireEvent.click(screen.getByTestId('use-ssh')) + // fireEvent.click( + // container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + // ) + // }) + // + // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + // + // await act(() => { + // fireEvent.change( + // screen.getByTestId('sshHost'), + // { target: { value: 'localhost' } } + // ) + // fireEvent.change( + // screen.getByTestId('sshUsername'), + // { target: { value: 'username' } } + // ) + // }) + // + // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + // + // await act(() => { + // fireEvent.change( + // screen.getByTestId('sshPrivateKey'), + // { target: { value: 'PRIVATEKEY' } } + // ) + // }) + // + // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + // }) + // + // it('should call submit btn with proper fields', async () => { + // const handleSubmit = jest.fn() + // render( + //
+ // + //
+ // ) + // + // await act(() => { + // fireEvent.click(screen.getByTestId('use-ssh')) + // }) + // + // await act(() => { + // fireEvent.change( + // screen.getByTestId('sshHost'), + // { target: { value: 'localhost' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshPort'), + // { target: { value: '1771' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshUsername'), + // { target: { value: 'username' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshPassword'), + // { target: { value: '123' } } + // ) + // }) + // + // await act(() => { + // fireEvent.click(screen.getByTestId(BTN_SUBMIT)) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // sshHost: 'localhost', + // sshPort: '1771', + // sshUsername: 'username', + // sshPassword: '123', + // }) + // ) + // }) + // + // it('should call submit btn with proper fields via passphrase', async () => { + // const handleSubmit = jest.fn() + // const { container } = render( + //
+ // + //
+ // ) + // + // await act(() => { + // fireEvent.click(screen.getByTestId('use-ssh')) + // fireEvent.click( + // container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + // ) + // }) + // + // await act(() => { + // fireEvent.change( + // screen.getByTestId('sshHost'), + // { target: { value: 'localhost' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshPort'), + // { target: { value: '1771' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshUsername'), + // { target: { value: 'username' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshPrivateKey'), + // { target: { value: '123444' } } + // ) + // + // fireEvent.change( + // screen.getByTestId('sshPassphrase'), + // { target: { value: '123444' } } + // ) + // }) + // + // await act(() => { + // fireEvent.click(screen.getByTestId(BTN_SUBMIT)) + // }) + // + // expect(handleSubmit).toBeCalledWith( + // expect.objectContaining({ + // sshHost: 'localhost', + // sshPort: '1771', + // sshUsername: 'username', + // sshPrivateKey: '123444', + // sshPassphrase: '123444', + // }) + // ) + // }) + // + // it('should render password input with 10_000 length limit', () => { + // render( + // + // ) + // + // expect(screen.getByTestId('password')).toHaveAttribute('maxLength', '10000') + // }) + // + // it('should render security fields with proper attributes', () => { + // render( + // + // ) + // + // expect(screen.getByTestId('password')).toHaveAttribute('value', '••••••••••••') + // expect(screen.getByTestId('password')).toHaveAttribute('type', 'password') + // expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '••••••••••••') + // expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('type', 'password') + // + // fireEvent.focus(screen.getByTestId('password')) + // fireEvent.focus(screen.getByTestId('sshPassphrase')) + // + // expect(screen.getByTestId('password')).toHaveAttribute('value', '') + // expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '') + // }) + // + // it('should render ssh password with proper attributes', () => { + // render( + // + // ) + // + // expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '••••••••••••') + // expect(screen.getByTestId('sshPassword')).toHaveAttribute('type', 'password') + // + // fireEvent.focus(screen.getByTestId('sshPassword')) + // + // expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '') + // }) + // + // it('should render ssh password input with 10_000 length limit', () => { + // render( + // + // ) + // + // expect(screen.getByTestId('sshPassword')).toHaveAttribute('maxLength', '10000') + // }) + // + // describe('timeout', () => { + // it('should render timeout input with 7 length limit and 1_000_000 value', () => { + // render( + // + // ) + // + // expect(screen.getByTestId('timeout')).toBeInTheDocument() + // expect(screen.getByTestId('timeout')).toHaveAttribute('maxLength', '7') + // + // fireEvent.change( + // screen.getByTestId('timeout'), + // { target: { value: '2000000' } } + // ) + // + // expect(screen.getByTestId('timeout')).toHaveAttribute('value', '1000000') + // }) + // + // it('should put only numbers', () => { + // render( + // + // ) + // + // fireEvent.change( + // screen.getByTestId('timeout'), + // { target: { value: '11a2EU$#@' } } + // ) + // + // expect(screen.getByTestId('timeout')).toHaveAttribute('value', '112') + // }) + // }) + // + // describe('cloud', () => { + // it('some fields should be readonly if instance data source from cloud', () => { + // (appRedirectionSelector as jest.Mock).mockImplementation(() => ({ + // action: UrlHandlingActions.Connect, + // })) + // + // const { queryByTestId } = render( + // + // ) + // + // expect(queryByTestId('connection-type')).not.toBeInTheDocument() + // expect(queryByTestId('host')).not.toBeInTheDocument() + // expect(queryByTestId('port')).not.toBeInTheDocument() + // expect(queryByTestId('db-info-port')).toBeInTheDocument() + // expect(queryByTestId('db-info-host')).toBeInTheDocument() + // }) + // }) +}) diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx similarity index 71% rename from redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx rename to redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx index 25cb5409e4..f0e5796d3f 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx @@ -28,7 +28,7 @@ import { DEFAULT_TIMEOUT, SubmitBtnText, } from 'uiSrc/pages/home/constants' -import ManualConnectionForm from './ManualConnectionForm' +import ManualConnectionForm from './manual-connection-form' export interface Props { width: number @@ -61,7 +61,6 @@ const ManualConnectionWrapper = (props: Props) => { const { properties: urlHandlingProperties } = useSelector(appRedirectionSelector) const connectionType = editedInstance?.connectionType ?? DbType.STANDALONE - const masterName = editedInstance?.sentinelMaster?.name const history = useHistory() const dispatch = useDispatch() @@ -72,7 +71,7 @@ const ManualConnectionWrapper = (props: Props) => { }, []) useEffect(() => { - setFormFields(getFormValues(editedInstance || initialValuesProp || null)) + setFormFields(getFormValues(editedInstance || initialValuesProp)) setIsCloneMode(false) }, [editedInstance, initialValuesProp]) @@ -95,12 +94,7 @@ const ManualConnectionWrapper = (props: Props) => { })) } - const handleSubmitDatabase = (payload: any) => { - if (isCloneMode && connectionType === ConnectionType.Sentinel) { - dispatch(createInstanceStandaloneAction(payload)) - return - } - + const handleAddDatabase = (payload: any) => { sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_MANUALLY_SUBMITTED }) @@ -136,56 +130,29 @@ const ManualConnectionWrapper = (props: Props) => { sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_TEST_CONNECTION_CLICKED }) - const { - name, - host, - port, - username, - password, - db, - compressor, - timeout, - sentinelMasterName, - sentinelMasterUsername, - sentinelMasterPassword, - } = values - - const tlsSettings = getTlsSettings(values) + const payload = preparePayload(values) - const database: any = { - name, - host, - port: +port, - db: +(db || 0), - username, - password, - compressor, - timeout: timeout ? toNumber(timeout) * 1_000 : toNumber(DEFAULT_TIMEOUT), - } + dispatch(testInstanceStandaloneAction(payload)) + } - // add tls & ssh for database (modifies database object) - applyTlSDatabase(database, tlsSettings) - applySSHDatabase(database, values) + const handleConnectionFormSubmit = (values: DbConnectionInfo) => { + const payload = preparePayload(values) - if (isCloneMode && connectionType === ConnectionType.Sentinel) { - database.sentinelMaster = { - name: sentinelMasterName, - username: sentinelMasterUsername, - password: sentinelMasterPassword, - } + if (isCloneMode) { + handleCloneDatabase(payload) + return } - - if (editMode && editedInstance) { - dispatch(testInstanceStandaloneAction({ - ...getFormUpdates(database, editedInstance), - id: editedInstance.id, - })) - } else { - dispatch(testInstanceStandaloneAction(removeEmpty(database))) + if (editMode) { + handleEditDatabase(payload) + return } + + handleAddDatabase(payload) } - const editDatabase = (tlsSettings: any, values: DbConnectionInfo, isCloneMode: boolean) => { + const preparePayload = (values: any) => { + const tlsSettings = getTlsSettings(values) + const { name, host, @@ -195,6 +162,7 @@ const ManualConnectionWrapper = (props: Props) => { password, timeout, compressor, + sentinelMasterName, sentinelMasterUsername, sentinelMasterPassword, } = values @@ -216,50 +184,6 @@ const ManualConnectionWrapper = (props: Props) => { applySSHDatabase(database, values) if (connectionType === ConnectionType.Sentinel) { - database.sentinelMaster = {} - database.sentinelMaster.name = masterName - database.sentinelMaster.username = sentinelMasterUsername - database.sentinelMaster.password = sentinelMasterPassword - } - - const payload = getFormUpdates(database, omit(editedInstance, ['id'])) - if (isCloneMode) { - handleCloneDatabase(payload) - } else { - handleEditDatabase(payload) - } - } - - const addDatabase = (tlsSettings: any, values: DbConnectionInfo) => { - const { - name, - host, - port, - username, - password, - timeout, - db, - compressor, - sentinelMasterName, - sentinelMasterUsername, - sentinelMasterPassword, - } = values - const database: any = { - name, - host, - port: +port, - db: +(db || 0), - compressor, - username, - password, - timeout: timeout ? toNumber(timeout) * 1_000 : toNumber(DEFAULT_TIMEOUT), - } - - // add tls & ssh for database (modifies database object) - applyTlSDatabase(database, tlsSettings) - applySSHDatabase(database, values) - - if (isCloneMode && connectionType === ConnectionType.Sentinel) { database.sentinelMaster = { name: sentinelMasterName, username: sentinelMasterUsername, @@ -267,17 +191,13 @@ const ManualConnectionWrapper = (props: Props) => { } } - handleSubmitDatabase(removeEmpty(database)) - } - - const handleConnectionFormSubmit = (values: DbConnectionInfo) => { - const tlsSettings = getTlsSettings(values) - if (editMode) { - editDatabase(tlsSettings, values, isCloneMode) - } else { - addDatabase(tlsSettings, values) + database.id = editedInstance?.id + + return getFormUpdates(database, omit(editedInstance, ['id'])) } + + return removeEmpty(database) } const handleOnClose = () => { diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/index.ts b/redisinsight/ui/src/pages/home/components/manual-connection/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/ManualConnection/index.ts rename to redisinsight/ui/src/pages/home/components/manual-connection/index.ts diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx similarity index 87% rename from redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx rename to redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx index 61e397234a..928cea130b 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx @@ -13,22 +13,13 @@ import { isEmpty, pick } from 'lodash' import React, { useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' import { useDispatch, useSelector } from 'react-redux' -import { useHistory } from 'react-router' -import { PageNames, Pages } from 'uiSrc/constants' import validationErrors from 'uiSrc/constants/validationErrors' -import DatabaseAlias from 'uiSrc/pages/home/components/DatabaseAlias' +import DatabaseAlias from 'uiSrc/pages/home/components/database-alias' import { useResizableFormField } from 'uiSrc/services' -import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' -import { resetKeys } from 'uiSrc/slices/browser/keys' -import { - changeInstanceAliasAction, - checkConnectToInstanceAction, - resetInstanceUpdateAction, - setConnectedInstanceId, -} from 'uiSrc/slices/instances/instances' +import { resetInstanceUpdateAction } from 'uiSrc/slices/instances/instances' import { ConnectionType } from 'uiSrc/slices/interfaces' -import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { getDiffKeysOfObjectValues, isRediStack } from 'uiSrc/utils' import { BuildType } from 'uiSrc/constants/env' import { appRedirectionSelector } from 'uiSrc/slices/app/url-handling' @@ -113,7 +104,6 @@ const ManualConnectionForm = (props: Props) => { version, } = formFields - const { contextInstanceId, lastPage } = useSelector(appContextSelector) const { action } = useSelector(appRedirectionSelector) const { data: caCertificates } = useSelector(caCertsSelector) const { data: certificates } = useSelector(clientCertsSelector) @@ -122,7 +112,6 @@ const ManualConnectionForm = (props: Props) => { getInitFieldsDisplayNames({ host, port, name }) ) - const history = useHistory() const dispatch = useDispatch() const formRef = useRef(null) @@ -184,73 +173,14 @@ const ManualConnectionForm = (props: Props) => { }, []) - const handleCheckConnectToInstance = () => { - const modulesSummary = getRedisModulesSummary(modules) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_OPEN_DATABASE_BUTTON_CLICKED, - eventData: { - databaseId: id, - provider, - ...modulesSummary, - } - }) - dispatch(checkConnectToInstanceAction(id, connectToInstance)) - } - - const handleCloneDatabase = () => { - setIsCloneMode(true) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_REQUESTED, - eventData: { - databaseId: id - } - }) - } - - const handleBackCloneDatabase = () => { - setIsCloneMode(false) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CANCELLED, - eventData: { - databaseId: id - } - }) - } + useEffect(() => { + formik.resetForm() + }, [isCloneMode]) const handleTestConnectionDatabase = () => { onTestConnection(formik.values) } - const handleChangeDatabaseAlias = ( - value: string, - onSuccess?: () => void, - onFail?: () => void - ) => { - dispatch(changeInstanceAliasAction( - id, - value, - () => { - onAliasEdited?.(value) - onSuccess?.() - }, - onFail - )) - } - - const connectToInstance = () => { - if (contextInstanceId && contextInstanceId !== id) { - dispatch(resetKeys()) - dispatch(setAppContextInitialState()) - } - dispatch(setConnectedInstanceId(id ?? '')) - - if (lastPage === PageNames.workbench && contextInstanceId === id) { - history.push(Pages.workbench(id)) - return - } - history.push(Pages.browser(id ?? '')) - } - const SubmitButton = ({ text = '', onClick, @@ -353,10 +283,11 @@ const ManualConnectionForm = (props: Props) => { alias={name} database={db} isLoading={loading} - onOpen={handleCheckConnectToInstance} - onClone={handleCloneDatabase} - onCloneBack={handleBackCloneDatabase} - onApplyChanges={handleChangeDatabaseAlias} + id={id} + provider={provider} + modules={modules} + setIsCloneMode={setIsCloneMode} + onAliasEdited={onAliasEdited} />
)} diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx similarity index 99% rename from redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx rename to redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx index 6cbfdbfde0..e86e1d2095 100644 --- a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/ManualConnectionFrom.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx @@ -24,8 +24,6 @@ const formFields = { host: 'localhost', port: '6379', name: 'lala', - caCertificates: [], - certificates: [], } jest.mock('uiSrc/slices/instances/instances', () => ({ @@ -264,7 +262,7 @@ describe('InstanceForm', () => { }) expect(handleTestConnection).toBeCalledWith( expect.objectContaining({ - showDb: true, + showDb: ['on'], }) ) await act(() => { @@ -273,7 +271,7 @@ describe('InstanceForm', () => { expect(handleSubmit).toBeCalledWith( expect.objectContaining({ - showDb: true, + showDb: ['on'], }) ) }) @@ -312,7 +310,7 @@ describe('InstanceForm', () => { }) expect(handleTestConnection).toBeCalledWith( expect.objectContaining({ - showDb: true, + showDb: ['on'], db: '12' }) ) @@ -322,7 +320,7 @@ describe('InstanceForm', () => { expect(handleSubmit).toBeCalledWith( expect.objectContaining({ - showDb: true, + showDb: ['on'], db: '12' }) ) @@ -357,7 +355,7 @@ describe('InstanceForm', () => { }) expect(handleTestConnection).toBeCalledWith( expect.objectContaining({ - sni: true, + sni: ['on'], servername: formFields.host }) ) @@ -367,7 +365,7 @@ describe('InstanceForm', () => { expect(handleSubmit).toBeCalledWith( expect.objectContaining({ - sni: true, + sni: ['on'], servername: formFields.host }) ) @@ -408,7 +406,7 @@ describe('InstanceForm', () => { }) expect(handleTestConnection).toBeCalledWith( expect.objectContaining({ - sni: true, + sni: ['on'], servername: '12' }) ) @@ -418,7 +416,7 @@ describe('InstanceForm', () => { expect(handleSubmit).toBeCalledWith( expect.objectContaining({ - sni: true, + sni: ['on'], servername: '12' }) ) @@ -774,7 +772,7 @@ describe('InstanceForm', () => { }} /> ) - expect(screen.getByTestId('showDb')).toBeChecked() + // expect(screen.getByTestId('showDb')).toBeChecked() expect(screen.getByTestId('db')).toHaveValue('5') }) @@ -860,7 +858,9 @@ describe('InstanceForm', () => {
) - fireEvent.click(screen.getByTestId('use-ssh')) + act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + }) expect(screen.getByTestId('sshHost')).toBeInTheDocument() expect(screen.getByTestId('sshPort')).toBeInTheDocument() diff --git a/redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/ManualConnection/ManualConnectionForm/index.ts rename to redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/index.ts diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.spec.tsx b/redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.spec.tsx rename to redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.tsx b/redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.tsx rename to redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.tsx diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/index.ts b/redisinsight/ui/src/pages/home/components/search-databases-list/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/SearchDatabasesList/index.ts rename to redisinsight/ui/src/pages/home/components/search-databases-list/index.ts diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/styles.module.scss b/redisinsight/ui/src/pages/home/components/search-databases-list/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/SearchDatabasesList/styles.module.scss rename to redisinsight/ui/src/pages/home/components/search-databases-list/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.tsx similarity index 94% rename from redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx rename to redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.tsx index d190ccb76f..a462959230 100644 --- a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.tsx @@ -11,9 +11,9 @@ import { clientCertsSelector, fetchClientCerts, } from 'uiSrc/slices/instances/c import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import { applyTlSDatabase, autoFillFormDetails, getTlsSettings } from 'uiSrc/pages/home/utils' -import { ADD_NEW, NO_CA_CERT, SubmitBtnText } from 'uiSrc/pages/home/constants' +import { ADD_NEW, NO_CA_CERT } from 'uiSrc/pages/home/constants' import { InstanceType } from 'uiSrc/slices/interfaces' -import SentinelConnectionForm from './SentinelConnectionForm' +import SentinelConnectionForm from './sentinel-connection-form' export interface Props { width: number @@ -101,7 +101,6 @@ const SentinelConnectionWrapper = (props: Props) => { width={width} initialValues={initialValues} loading={loading} - submitButtonText={SubmitBtnText.AddDatabase} onSubmit={handleConnectionFormSubmit} onClose={onClose} onHostNamePaste={handlePostHostName} diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/index.ts b/redisinsight/ui/src/pages/home/components/sentinel-connection/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/SentinelConnection/index.ts rename to redisinsight/ui/src/pages/home/components/sentinel-connection/index.ts diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.spec.tsx b/redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.spec.tsx rename to redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm.tsx similarity index 98% rename from redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx rename to redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm.tsx index 48b714cd45..be8dd68cbf 100644 --- a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/SentinelConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm.tsx @@ -52,7 +52,6 @@ const SentinelConnectionForm = (props: Props) => { onClose, onSubmit, onHostNamePaste, - submitButtonText, loading, certificates, caCertificates, @@ -94,7 +93,6 @@ const SentinelConnectionForm = (props: Props) => { } const SubmitButton = ({ - text = '', onClick, submitIsDisabled, }: ISubmitButton) => ( @@ -118,7 +116,7 @@ const SentinelConnectionForm = (props: Props) => { iconType={submitIsDisabled ? 'iInCircle' : undefined} data-testid="btn-submit" > - {text} + Discover Database ) @@ -150,7 +148,6 @@ const SentinelConnectionForm = (props: Props) => { )} diff --git a/redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/index.ts b/redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/SentinelConnection/SentinelConnectionForm/index.ts rename to redisinsight/ui/src/pages/home/components/sentinel-connection/sentinel-connection-form/index.ts diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx b/redisinsight/ui/src/pages/home/components/welcome-component/WelcomeComponent.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx rename to redisinsight/ui/src/pages/home/components/welcome-component/WelcomeComponent.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx b/redisinsight/ui/src/pages/home/components/welcome-component/WelcomeComponent.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx rename to redisinsight/ui/src/pages/home/components/welcome-component/WelcomeComponent.tsx diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/index.ts b/redisinsight/ui/src/pages/home/components/welcome-component/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/WelcomeComponent/index.ts rename to redisinsight/ui/src/pages/home/components/welcome-component/index.ts diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/styles.module.scss b/redisinsight/ui/src/pages/home/components/welcome-component/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/WelcomeComponent/styles.module.scss rename to redisinsight/ui/src/pages/home/components/welcome-component/styles.module.scss diff --git a/redisinsight/ui/src/pages/home/constants/form.ts b/redisinsight/ui/src/pages/home/constants/form.ts index bb665208a3..5229a0a649 100644 --- a/redisinsight/ui/src/pages/home/constants/form.ts +++ b/redisinsight/ui/src/pages/home/constants/form.ts @@ -35,7 +35,6 @@ export const DEFAULT_TIMEOUT = parseInt(DEFAULT_TIMEOUT_ENV, 10) export enum SubmitBtnText { AddDatabase = 'Add Redis Database', - EditDatabase = 'Apply changes', - ConnectToSentinel = 'Discover database', + EditDatabase = 'Apply Changes', CloneDatabase = 'Clone Database' } diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx index 9166ea7aaa..4d002076a3 100644 --- a/redisinsight/ui/src/pages/home/utils/form.tsx +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -286,7 +286,8 @@ export const getSubmitButtonContent = (errors: FormikErrors, s ) : null } -export const getFormValues = (instance: Nullable>) => ({ +export const getFormValues = (instance?: Nullable>) => ({ + id: instance?.id, host: instance?.host ?? (instance ? '' : DEFAULT_HOST), port: instance?.port?.toString() ?? (instance ? '' : DEFAULT_PORT), timeout: instance?.timeout diff --git a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx index 0d8d34eaeb..c68c2a7cec 100644 --- a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx +++ b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx @@ -18,7 +18,7 @@ import { HELP_LINKS } from 'uiSrc/pages/home/constants' import { sendEventTelemetry } from 'uiSrc/telemetry' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' -import DatabasePanel from 'uiSrc/pages/home/components/DatabasePanel/DatabasePanel' +import DatabasePanel from 'uiSrc/pages/home/components/database-panel/DatabasePanel' import './styles.scss' import styles from './styles.module.scss' From 165588e6eabc6e23aa38d8864c9db99bd089c2ba Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 9 Nov 2023 16:16:55 +0400 Subject: [PATCH 29/96] #RI-5009 - fix unit tests --- .../ClusterConnectionFormWrapper.spec.tsx | 2 +- .../ManualConnectionWrapper.spec.tsx | 1247 ----------------- .../ManualConnectionFrom.spec.tsx | 59 +- .../SentinelConnectionWrapper.spec.tsx | 66 + redisinsight/ui/src/pages/home/utils/form.tsx | 6 +- 5 files changed, 111 insertions(+), 1269 deletions(-) delete mode 100644 redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx index 341145e689..3502290daf 100644 --- a/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.spec.tsx @@ -9,7 +9,7 @@ import ClusterConnectionFormWrapper, { const mockedProps = mock() -jest.mock('./cluster-connection-form/cluster-connection-form', () => ({ +jest.mock('./cluster-connection-form/ClusterConnectionForm', () => ({ __esModule: true, namedExport: jest.fn(), default: jest.fn(), diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx deleted file mode 100644 index afd4dbcee5..0000000000 --- a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx +++ /dev/null @@ -1,1247 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import { ConnectionType } from 'uiSrc/slices/interfaces' -import { BuildType } from 'uiSrc/constants/env' -import { appRedirectionSelector } from 'uiSrc/slices/app/url-handling' -import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' -import { ADD_NEW_CA_CERT, SshPassType } from 'uiSrc/pages/home/constants' -import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' - -import ManualConnectionWrapper, { Props } from './ManualConnectionWrapper' - -const BTN_SUBMIT = 'btn-submit' -const NEW_CA_CERT = 'new-ca-cert' -const QA_CA_CERT = 'qa-ca-cert' -const RADIO_BTN_PRIVATE_KEY = '[data-test-subj="radio-btn-privateKey"] label' -const BTN_TEST_CONNECTION = 'btn-test-connection' - -const mockedProps = mock() -const mockedDbConnectionInfo = mock() - -const formFields = { - ...instance(mockedDbConnectionInfo), - host: 'localhost', - port: '6379', - name: 'lala', -} - -// jest.mock('uiSrc/slices/instances/instances', () => ({ -// checkConnectToInstanceAction: () => jest.fn, -// resetInstanceUpdateAction: () => jest.fn, -// changeInstanceAliasAction: () => jest.fn, -// setConnectedInstanceId: jest.fn, -// })) -// -// jest.mock('uiSrc/slices/app/url-handling', () => ({ -// ...jest.requireActual('uiSrc/slices/app/url-handling'), -// appRedirectionSelector: jest.fn().mockReturnValue(() => ({ action: null })), -// })) - -describe('InstanceForm', () => { - it('should render', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - // it('should render with ConnectionType.Sentinel', () => { - // expect( - // render( - // - // ) - // ).toBeTruthy() - // }) - // - // it('should render with ConnectionType.Cluster', () => { - // expect( - // render( - // - // ) - // ).toBeTruthy() - // }) - // - // it('should render tooltip with nodes', () => { - // expect( - // render( - // - // ) - // ).toBeTruthy() - // }) - // - it('should render DatabaseForm', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - // - // it('should change sentinelMasterUsername input properly', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // - // render( - //
- // - //
- // ) - // - // await act(() => { - // fireEvent.change(screen.getByTestId('sentinel-mater-username'), { - // target: { value: 'user' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // sentinelMasterUsername: 'user', - // }) - // ) - // - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // sentinelMasterUsername: 'user', - // }) - // ) - // }) - // - // it('should change port input properly', async () => { - // const handleSubmit = jest.fn() - // render( - //
- // - //
- // ) - // - // await act(() => { - // fireEvent.change(screen.getByTestId('port'), { - // target: { value: '123' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // port: '123', - // }) - // ) - // }) - // - // it('should change tls checkbox', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('tls')) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // tls: ['on'], - // }) - // ) - // - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // tls: ['on'], - // }) - // ) - // }) - // - // it('should change Database Index checkbox', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('showDb')) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // showDb: ['on'], - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // showDb: ['on'], - // }) - // ) - // }) - // - // it('should change db checkbox and value', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('showDb')) - // }) - // - // await act(() => { - // fireEvent.change(screen.getByTestId('db'), { - // target: { value: '12' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // showDb: ['on'], - // db: '12' - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // showDb: ['on'], - // db: '12' - // }) - // ) - // }) - // - // it('should change "Use SNI" with prepopulated with host', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('sni')) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // sni: ['on'], - // servername: formFields.host - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // sni: ['on'], - // servername: formFields.host - // }) - // ) - // }) - // - // it('should change "Use SNI"', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('sni')) - // }) - // - // await act(() => { - // fireEvent.change(screen.getByTestId('sni-servername'), { - // target: { value: '12' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // sni: ['on'], - // servername: '12' - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // sni: ['on'], - // servername: '12' - // }) - // ) - // }) - // - // it('should change "Verify TLS Certificate"', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('verify-tls-cert')) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // verifyServerTlsCert: ['on'], - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // verifyServerTlsCert: ['on'], - // }) - // ) - // }) - // - // it('should select value from "CA Certificate"', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // const { queryByText } = render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('select-ca-cert')) - // }) - // await act(() => { - // fireEvent.click(queryByText('Add new CA certificate') || document) - // }) - // - // expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId(NEW_CA_CERT), { - // target: { value: '123' }, - // }) - // }) - // - // expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId(QA_CA_CERT), { - // target: { value: '321' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // selectedCaCertName: ADD_NEW_CA_CERT, - // newCaCertName: '321', - // newCaCert: '123', - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // selectedCaCertName: ADD_NEW_CA_CERT, - // newCaCertName: '321', - // newCaCert: '123', - // }) - // ) - // }) - // - // it('should render fields for add new CA and change them properly', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // - // expect(screen.getByTestId(QA_CA_CERT)).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId(QA_CA_CERT), { - // target: { value: '321' }, - // }) - // }) - // - // expect(screen.getByTestId(NEW_CA_CERT)).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId(NEW_CA_CERT), { - // target: { value: '123' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // newCaCert: '123', - // newCaCertName: '321', - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // newCaCert: '123', - // newCaCertName: '321', - // }) - // ) - // }) - // - // it('should change "Requires TLS Client Authentication"', async () => { - // const handleSubmit = jest.fn() - // const handleTestConnection = jest.fn() - // render( - //
- // - //
- // ) - // await act(() => { - // fireEvent.click(screen.getByTestId('tls-required-checkbox')) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // const testConnectionBtn = screen.getByTestId(BTN_TEST_CONNECTION) - // await act(() => { - // fireEvent.click(testConnectionBtn) - // }) - // expect(handleTestConnection).toBeCalledWith( - // expect.objectContaining({ - // tlsClientAuthRequired: ['on'], - // }) - // ) - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // tlsClientAuthRequired: ['on'], - // }) - // ) - // }) - // - // it('should render fields for add new CA with required tls auth and change them properly', async () => { - // const handleSubmit = jest.fn() - // const { container } = render( - //
- // - //
- // ) - // - // expect(screen.getByTestId('select-cert')).toBeInTheDocument() - // - // await act(() => { - // fireEvent.click(screen.getByTestId('select-cert')) - // }) - // - // await act(() => { - // fireEvent.click( - // container.querySelectorAll('.euiContextMenuItem__text')[0] || document - // ) - // }) - // - // expect(screen.getByTestId('new-tsl-cert-pair-name')).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId('new-tsl-cert-pair-name'), { - // target: { value: '123' }, - // }) - // }) - // - // expect(screen.getByTestId('new-tls-client-cert')).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId('new-tls-client-cert'), { - // target: { value: '321' }, - // }) - // }) - // - // expect(screen.getByTestId('new-tls-client-cert-key')).toBeInTheDocument() - // await act(() => { - // fireEvent.change(screen.getByTestId('new-tls-client-cert-key'), { - // target: { value: '231' }, - // }) - // }) - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // - // await act(() => { - // fireEvent.click(submitBtn) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // newTlsClientCert: '321', - // newTlsCertPairName: '123', - // newTlsClientKey: '231', - // }) - // ) - // }) - // - // it('should render clone mode btn', () => { - // render( - // - // ) - // expect(screen.getByTestId('clone-db-btn')).toBeTruthy() - // }) - // - // describe('should render proper fields with Clone mode', () => { - // it('should render proper fields for standalone db', () => { - // render( - // - // ) - // const fieldsTestIds = ['host', 'port', 'username', 'password', 'showDb', 'tls'] - // fieldsTestIds.forEach((id) => { - // expect(screen.getByTestId(id)).toBeTruthy() - // }) - // }) - // - // it('should render proper fields for sentinel db', () => { - // render( - // - // ) - // const fieldsTestIds = [ - // 'name', - // 'primary-group', - // 'sentinel-mater-username', - // 'sentinel-master-password', - // 'host', - // 'port', - // 'username', - // 'password', - // 'showDb', - // 'tls' - // ] - // fieldsTestIds.forEach((id) => { - // expect(screen.getByTestId(id)).toBeTruthy() - // }) - // }) - // - // it('should render selected logical database with proper db index', () => { - // render( - // - // ) - // // expect(screen.getByTestId('showDb')).toBeChecked() - // expect(screen.getByTestId('db')).toHaveValue('5') - // }) - // - // it('should render proper database alias', () => { - // render( - // - // ) - // expect(screen.getByTestId('db-alias')).toHaveTextContent('Clone ') - // }) - // - // it('should render proper default values for standalone', () => { - // render( - // - // ) - // expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') - // expect(screen.getByTestId('port')).toHaveValue('6379') - // expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') - // }) - // }) - // - // it('should change Use SSH checkbox', async () => { - // const handleSubmit = jest.fn() - // render( - //
- // - //
- // ) - // - // fireEvent.click(screen.getByTestId('use-ssh')) - // - // expect(screen.getByTestId('use-ssh')).toBeChecked() - // }) - // - // it('should not render Use SSH checkbox for redis stack buidlType', async () => { - // const handleSubmit = jest.fn() - // render( - //
- // - //
- // ) - // - // expect(screen.queryByTestId('use-ssh')).not.toBeInTheDocument() - // }) - // - // it('should change Use SSH checkbox and show proper fields for password radio', async () => { - // const handleSubmit = jest.fn() - // render( - //
- // - //
- // ) - // - // act(() => { - // fireEvent.click(screen.getByTestId('use-ssh')) - // }) - // - // expect(screen.getByTestId('sshHost')).toBeInTheDocument() - // expect(screen.getByTestId('sshPort')).toBeInTheDocument() - // expect(screen.getByTestId('sshPort')).toHaveValue('22') - // expect(screen.getByTestId('sshPassword')).toBeInTheDocument() - // expect(screen.queryByTestId('sshPrivateKey')).not.toBeInTheDocument() - // expect(screen.queryByTestId('sshPassphrase')).not.toBeInTheDocument() - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // expect(submitBtn).toBeDisabled() - // }) - // - // it('should change Use SSH checkbox and show proper fields for passphrase radio', async () => { - // const handleSubmit = jest.fn() - // const { container } = render( - //
- // - //
- // ) - // - // await act(() => { - // fireEvent.click(screen.getByTestId('use-ssh')) - // fireEvent.click( - // container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement - // ) - // }) - // - // expect(screen.getByTestId('sshHost')).toBeInTheDocument() - // expect(screen.getByTestId('sshPort')).toBeInTheDocument() - // expect(screen.getByTestId('sshPort')).toHaveValue('22') - // expect(screen.queryByTestId('sshPassword')).not.toBeInTheDocument() - // expect(screen.getByTestId('sshPrivateKey')).toBeInTheDocument() - // expect(screen.getByTestId('sshPassphrase')).toBeInTheDocument() - // - // const submitBtn = screen.getByTestId(BTN_SUBMIT) - // expect(submitBtn).toBeDisabled() - // }) - // - // it('should be proper validation for ssh via ssh password', async () => { - // const handleSubmit = jest.fn() - // render( - //
- // - //
- // ) - // - // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - // - // await act(() => { - // fireEvent.click(screen.getByTestId('use-ssh')) - // }) - // - // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - // - // await act(() => { - // fireEvent.change( - // screen.getByTestId('sshHost'), - // { target: { value: 'localhost' } } - // ) - // }) - // - // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - // - // await act(() => { - // fireEvent.change( - // screen.getByTestId('sshUsername'), - // { target: { value: 'username' } } - // ) - // }) - // - // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - // }) - // - // it('should be proper validation for ssh via ssh passphrase', async () => { - // const handleSubmit = jest.fn() - // const { container } = render( - //
- // - //
- // ) - // - // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - // - // await act(() => { - // fireEvent.click(screen.getByTestId('use-ssh')) - // fireEvent.click( - // container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement - // ) - // }) - // - // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - // - // await act(() => { - // fireEvent.change( - // screen.getByTestId('sshHost'), - // { target: { value: 'localhost' } } - // ) - // fireEvent.change( - // screen.getByTestId('sshUsername'), - // { target: { value: 'username' } } - // ) - // }) - // - // expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() - // - // await act(() => { - // fireEvent.change( - // screen.getByTestId('sshPrivateKey'), - // { target: { value: 'PRIVATEKEY' } } - // ) - // }) - // - // expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() - // }) - // - // it('should call submit btn with proper fields', async () => { - // const handleSubmit = jest.fn() - // render( - //
- // - //
- // ) - // - // await act(() => { - // fireEvent.click(screen.getByTestId('use-ssh')) - // }) - // - // await act(() => { - // fireEvent.change( - // screen.getByTestId('sshHost'), - // { target: { value: 'localhost' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshPort'), - // { target: { value: '1771' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshUsername'), - // { target: { value: 'username' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshPassword'), - // { target: { value: '123' } } - // ) - // }) - // - // await act(() => { - // fireEvent.click(screen.getByTestId(BTN_SUBMIT)) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // sshHost: 'localhost', - // sshPort: '1771', - // sshUsername: 'username', - // sshPassword: '123', - // }) - // ) - // }) - // - // it('should call submit btn with proper fields via passphrase', async () => { - // const handleSubmit = jest.fn() - // const { container } = render( - //
- // - //
- // ) - // - // await act(() => { - // fireEvent.click(screen.getByTestId('use-ssh')) - // fireEvent.click( - // container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement - // ) - // }) - // - // await act(() => { - // fireEvent.change( - // screen.getByTestId('sshHost'), - // { target: { value: 'localhost' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshPort'), - // { target: { value: '1771' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshUsername'), - // { target: { value: 'username' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshPrivateKey'), - // { target: { value: '123444' } } - // ) - // - // fireEvent.change( - // screen.getByTestId('sshPassphrase'), - // { target: { value: '123444' } } - // ) - // }) - // - // await act(() => { - // fireEvent.click(screen.getByTestId(BTN_SUBMIT)) - // }) - // - // expect(handleSubmit).toBeCalledWith( - // expect.objectContaining({ - // sshHost: 'localhost', - // sshPort: '1771', - // sshUsername: 'username', - // sshPrivateKey: '123444', - // sshPassphrase: '123444', - // }) - // ) - // }) - // - // it('should render password input with 10_000 length limit', () => { - // render( - // - // ) - // - // expect(screen.getByTestId('password')).toHaveAttribute('maxLength', '10000') - // }) - // - // it('should render security fields with proper attributes', () => { - // render( - // - // ) - // - // expect(screen.getByTestId('password')).toHaveAttribute('value', '••••••••••••') - // expect(screen.getByTestId('password')).toHaveAttribute('type', 'password') - // expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '••••••••••••') - // expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('type', 'password') - // - // fireEvent.focus(screen.getByTestId('password')) - // fireEvent.focus(screen.getByTestId('sshPassphrase')) - // - // expect(screen.getByTestId('password')).toHaveAttribute('value', '') - // expect(screen.getByTestId('sshPassphrase')).toHaveAttribute('value', '') - // }) - // - // it('should render ssh password with proper attributes', () => { - // render( - // - // ) - // - // expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '••••••••••••') - // expect(screen.getByTestId('sshPassword')).toHaveAttribute('type', 'password') - // - // fireEvent.focus(screen.getByTestId('sshPassword')) - // - // expect(screen.getByTestId('sshPassword')).toHaveAttribute('value', '') - // }) - // - // it('should render ssh password input with 10_000 length limit', () => { - // render( - // - // ) - // - // expect(screen.getByTestId('sshPassword')).toHaveAttribute('maxLength', '10000') - // }) - // - // describe('timeout', () => { - // it('should render timeout input with 7 length limit and 1_000_000 value', () => { - // render( - // - // ) - // - // expect(screen.getByTestId('timeout')).toBeInTheDocument() - // expect(screen.getByTestId('timeout')).toHaveAttribute('maxLength', '7') - // - // fireEvent.change( - // screen.getByTestId('timeout'), - // { target: { value: '2000000' } } - // ) - // - // expect(screen.getByTestId('timeout')).toHaveAttribute('value', '1000000') - // }) - // - // it('should put only numbers', () => { - // render( - // - // ) - // - // fireEvent.change( - // screen.getByTestId('timeout'), - // { target: { value: '11a2EU$#@' } } - // ) - // - // expect(screen.getByTestId('timeout')).toHaveAttribute('value', '112') - // }) - // }) - // - // describe('cloud', () => { - // it('some fields should be readonly if instance data source from cloud', () => { - // (appRedirectionSelector as jest.Mock).mockImplementation(() => ({ - // action: UrlHandlingActions.Connect, - // })) - // - // const { queryByTestId } = render( - // - // ) - // - // expect(queryByTestId('connection-type')).not.toBeInTheDocument() - // expect(queryByTestId('host')).not.toBeInTheDocument() - // expect(queryByTestId('port')).not.toBeInTheDocument() - // expect(queryByTestId('db-info-port')).toBeInTheDocument() - // expect(queryByTestId('db-info-host')).toBeInTheDocument() - // }) - // }) -}) diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx index e86e1d2095..e86da12128 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx @@ -768,11 +768,12 @@ describe('InstanceForm', () => { formFields={{ ...formFields, connectionType: ConnectionType.Standalone, + showDb: true, db: 5 }} /> ) - // expect(screen.getByTestId('showDb')).toBeChecked() + expect(screen.getByTestId('showDb')).toBeChecked() expect(screen.getByTestId('db')).toHaveValue('5') }) @@ -791,17 +792,17 @@ describe('InstanceForm', () => { expect(screen.getByTestId('db-alias')).toHaveTextContent('Clone ') }) - it('should render proper default values for standalone', () => { - render( - - ) - expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') - expect(screen.getByTestId('port')).toHaveValue('6379') - expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') - }) + // it('should render proper default values for standalone', () => { + // render( + // + // ) + // expect(screen.getByTestId('host')).toHaveValue('127.0.0.1') + // expect(screen.getByTestId('port')).toHaveValue('6379') + // expect(screen.getByTestId('name')).toHaveValue('127.0.0.1:6379') + // }) }) it('should change Use SSH checkbox', async () => { @@ -819,7 +820,9 @@ describe('InstanceForm', () => {
) - fireEvent.click(screen.getByTestId('use-ssh')) + act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + }) expect(screen.getByTestId('use-ssh')).toBeChecked() }) @@ -851,6 +854,7 @@ describe('InstanceForm', () => { {...instance(mockedProps)} formFields={{ ...formFields, + sshPassType: SshPassType.Password, connectionType: ConnectionType.Standalone, }} onSubmit={handleSubmit} @@ -864,7 +868,6 @@ describe('InstanceForm', () => { expect(screen.getByTestId('sshHost')).toBeInTheDocument() expect(screen.getByTestId('sshPort')).toBeInTheDocument() - expect(screen.getByTestId('sshPort')).toHaveValue('22') expect(screen.getByTestId('sshPassword')).toBeInTheDocument() expect(screen.queryByTestId('sshPrivateKey')).not.toBeInTheDocument() expect(screen.queryByTestId('sshPassphrase')).not.toBeInTheDocument() @@ -897,7 +900,6 @@ describe('InstanceForm', () => { expect(screen.getByTestId('sshHost')).toBeInTheDocument() expect(screen.getByTestId('sshPort')).toBeInTheDocument() - expect(screen.getByTestId('sshPort')).toHaveValue('22') expect(screen.queryByTestId('sshPassword')).not.toBeInTheDocument() expect(screen.getByTestId('sshPrivateKey')).toBeInTheDocument() expect(screen.getByTestId('sshPassphrase')).toBeInTheDocument() @@ -915,6 +917,7 @@ describe('InstanceForm', () => { formFields={{ ...formFields, connectionType: ConnectionType.Standalone, + sshPassType: SshPassType.Password, }} onSubmit={handleSubmit} /> @@ -945,6 +948,15 @@ describe('InstanceForm', () => { ) }) + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshPort'), + { target: { value: '22' } } + ) + }) + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() }) @@ -957,6 +969,7 @@ describe('InstanceForm', () => { formFields={{ ...formFields, connectionType: ConnectionType.Standalone, + sshPassType: SshPassType.Password, }} onSubmit={handleSubmit} /> @@ -979,6 +992,10 @@ describe('InstanceForm', () => { screen.getByTestId('sshHost'), { target: { value: 'localhost' } } ) + fireEvent.change( + screen.getByTestId('sshPort'), + { target: { value: '22' } } + ) fireEvent.change( screen.getByTestId('sshUsername'), { target: { value: 'username' } } @@ -1006,6 +1023,7 @@ describe('InstanceForm', () => { formFields={{ ...formFields, connectionType: ConnectionType.Standalone, + sshPassType: SshPassType.Password, }} onSubmit={handleSubmit} /> @@ -1136,7 +1154,7 @@ describe('InstanceForm', () => { connectionType: ConnectionType.Standalone, ssh: true, password: true, - sshOptions: { host: 'host', port: 123, passphrase: true }, + sshPassphrase: true, sshPassType: SshPassType.PrivateKey, }} /> @@ -1162,7 +1180,7 @@ describe('InstanceForm', () => { ...formFields, connectionType: ConnectionType.Standalone, ssh: true, - sshOptions: { host: 'host', port: 123, password: true }, + sshPassword: true, sshPassType: SshPassType.Password, }} /> @@ -1180,7 +1198,12 @@ describe('InstanceForm', () => { render( ) diff --git a/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx new file mode 100644 index 0000000000..c4f4c6886d --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import SentinelConnectionForm, { Props as SentinelConnectionFormProps } from + 'uiSrc/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm' +import SentinelConnectionWrapper, { + Props, +} from './SentinelConnectionWrapper' + +const mockedProps = mock() + +jest.mock('./sentinel-connection-form/SentinelConnectionForm', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn(), +})) + +const mockSentinelConnectionForm = (props: SentinelConnectionFormProps) => ( +
+ + + +
+) + +describe('SentinelConnectionWrapper', () => { + beforeAll(() => { + SentinelConnectionForm.mockImplementation(mockSentinelConnectionForm) + }) + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) + + it('should call onHostNamePaste', () => { + const component = render() + fireEvent.click(screen.getByTestId('onHostNamePaste-btn')) + expect(component).toBeTruthy() + }) + + it('should call onClose', () => { + const onClose = jest.fn() + render() + fireEvent.click(screen.getByTestId('onClose-btn')) + expect(onClose).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx index 4d002076a3..c11312af5f 100644 --- a/redisinsight/ui/src/pages/home/utils/form.tsx +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -287,7 +287,7 @@ export const getSubmitButtonContent = (errors: FormikErrors, s } export const getFormValues = (instance?: Nullable>) => ({ - id: instance?.id, + ...instance, host: instance?.host ?? (instance ? '' : DEFAULT_HOST), port: instance?.port?.toString() ?? (instance ? '' : DEFAULT_PORT), timeout: instance?.timeout @@ -314,8 +314,8 @@ export const getFormValues = (instance?: Nullable>) => ({ newTlsClientCert: '', newTlsClientKey: '', sentinelMasterName: instance?.sentinelMaster?.name || '', - sentinelMasterUsername: instance?.sentinelMasterUsername, - sentinelMasterPassword: instance?.sentinelMasterPassword, + sentinelMasterUsername: instance?.sentinelMaster?.username, + sentinelMasterPassword: instance?.sentinelMaster?.password, ssh: instance?.ssh ?? false, sshPassType: instance?.sshOptions ? (instance.sshOptions.privateKey ? SshPassType.PrivateKey : SshPassType.Password) From 370b69f7b4afe7f3dccb0a2e9af7a39a00d78645 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 9 Nov 2023 17:28:38 +0400 Subject: [PATCH 30/96] #RI-5009 - fix tests --- .../database-alias/DatabaseAlias.spec.tsx | 27 ------------------- .../DatabasesListWrapper.spec.tsx | 4 +-- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.spec.tsx b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.spec.tsx index 87cc8a70db..889a36bd66 100644 --- a/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.spec.tsx @@ -22,25 +22,6 @@ describe('DatabaseAlias', () => { expect(render()).toBeTruthy() }) - it('should call onApplyChanges on edit alias', () => { - const onApply = jest.fn() - render() - - fireEvent.click(screen.getByTestId('edit-alias-btn')) - fireEvent.change(screen.getByTestId('alias-input'), { target: { value: 'alias' } }) - fireEvent.submit(screen.getByTestId('alias-input')) - - expect(onApply).toHaveBeenCalledWith('alias', expect.anything(), expect.anything()) - }) - - it('should call onOpen', () => { - const onOpen = jest.fn() - render() - - fireEvent.click(screen.getByTestId('connect-to-db-btn')) - expect(onOpen).toHaveBeenCalled() - }) - it('should not render part of content in edit mode', () => { render() @@ -48,14 +29,6 @@ describe('DatabaseAlias', () => { expect(screen.queryByTestId('db-alias')).toHaveTextContent('alias') }) - it('should call onCloneBack in clone mode', () => { - const onCloneBack = jest.fn() - render() - - fireEvent.click(screen.getByTestId('back-btn')) - expect(onCloneBack).toHaveBeenCalled() - }) - it('should render icon for redis-stack', () => { render() diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx index 3c2a57ab72..c1f6d74d86 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx @@ -11,11 +11,11 @@ import { RootState, store } from 'uiSrc/slices/store' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' import DatabasesListWrapper, { Props } from './DatabasesListWrapper' -import DatabasesList, { Props as DatabasesListProps } from './databases-list' +import DatabasesList, { Props as DatabasesListProps } from './databases-list/DatabasesList' const mockedProps = mock() -jest.mock('./databases-list/databases-list', () => ({ +jest.mock('./databases-list/DatabasesList', () => ({ __esModule: true, namedExport: jest.fn(), default: jest.fn(), From 09369f372253b804693f4f9205e39e7d877d4a4f Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 10 Nov 2023 10:41:00 +0400 Subject: [PATCH 31/96] #RI-5122 - string value truncate --- .../string-details/StringDetails.spec.tsx | 28 +++++++++++++++++++ .../string-details/StringDetails.tsx | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx index 1d525b2a8b..0553462cdd 100644 --- a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx @@ -190,6 +190,34 @@ describe('StringDetails', () => { }) }) + it('Should add "..." in the end of the part value', async () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: partValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + expect(screen.getByTestId(STRING_VALUE)).toHaveTextContent(`${bufferToString(partValue)}...`) + }) + + it('Should not add "..." in the end of the full value', async () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: fullValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + expect(screen.getByTestId(STRING_VALUE)).toHaveTextContent(bufferToString(fullValue)) + }) + it('should call fetchDownloadStringValue and sendEventTelemetry after clicking on load button and download button', async () => { const stringDataSelectorMock = jest.fn().mockReturnValue({ value: partValue diff --git a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx index 4aa54fa164..4c88f2adf5 100644 --- a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx @@ -99,7 +99,7 @@ const StringDetails = (props: Props) => { const { value: formattedValue, isValid } = formattingBuffer(decompressedValue, viewFormatProp, { expanded: true }) setAreaValue(initialValueString) - setValue(formattedValue) + setValue(!isFullStringLoaded(initialValue?.data?.length, length) ? `${formattedValue}...` : formattedValue) setIsValid(isValid) setIsDisabled( !isNonUnicodeFormatter(viewFormatProp, isValid) From bfb1487c06cfa5d087fcd87c4f46a03035f0669e Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 10 Nov 2023 12:19:47 +0400 Subject: [PATCH 32/96] #RI-5009 - add tests --- .../ManualConnectionWrapper.spec.tsx | 139 ++++++++++++++++++ .../SentinelConnectionWrapper.spec.tsx | 24 ++- .../SentinelConnectionForm.spec.tsx | 18 ++- .../SentinelConnectionForm.tsx | 3 - 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx new file mode 100644 index 0000000000..0ee1ee6e8a --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { act } from '@testing-library/react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { + DEFAULT_TIMEOUT, + SubmitBtnText, +} from 'uiSrc/pages/home/constants' +import ManualConnectionFrom, { Props as ManualConnectionFromProps } from + 'uiSrc/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import SentinelConnectionWrapper from 'uiSrc/pages/home/components/sentinel-connection' +import ManualConnectionWrapper, { + Props, +} from './ManualConnectionWrapper' + +const mockedProps = mock() + +jest.mock('./manual-connection-form/ManualConnectionForm', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn(), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const mockManualConnectionFrom = (props: ManualConnectionFromProps) => ( +
+ + + + +
+) + +describe('ManualConnectionWrapper', () => { + beforeAll(() => { + ManualConnectionFrom.mockImplementation(mockManualConnectionFrom) + }) + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) + + it('should call onHostNamePaste', () => { + const component = render() + fireEvent.click(screen.getByTestId('onHostNamePaste-btn')) + expect(component).toBeTruthy() + }) + + it('should call onClose', () => { + const onClose = jest.fn() + render() + fireEvent.click(screen.getByTestId('onClose-btn')) + expect(onClose).toBeCalled() + }) + + it('should have add database submit button', () => { + render() + expect(screen.getByTestId('btn-submit')).toHaveTextContent(SubmitBtnText.AddDatabase) + }) + + it('should have edit database submit button', () => { + render() + expect(screen.getByTestId('btn-submit')).toHaveTextContent(SubmitBtnText.EditDatabase) + }) + + it('should have edit database submit button', () => { + render() + act(() => { + fireEvent.click(screen.getByTestId('onClone-btn')) + }) + expect(screen.getByTestId('btn-submit')).toHaveTextContent(SubmitBtnText.CloneDatabase) + }) + + it('should call proper telemetry event on Add database', () => { + const sendEventTelemetryMock = jest.fn() + + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + sendEventTelemetry.mockRestore() + render() + act(() => { + fireEvent.click(screen.getByTestId('btn-submit')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_MANUALLY_SUBMITTED, + }) + }) + + it('should call proper telemetry event on Clone database', () => { + const sendEventTelemetryMock = jest.fn() + + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + sendEventTelemetry.mockRestore() + render() + act(() => { + fireEvent.click(screen.getByTestId('onClone-btn')) + }) + act(() => { + fireEvent.click(screen.getByTestId('onClose-btn')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_CLONE_CANCELLED, + eventData: { databaseId: undefined } + }) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx index c4f4c6886d..b37d75b206 100644 --- a/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/sentinel-connection/SentinelConnectionWrapper.spec.tsx @@ -3,6 +3,7 @@ import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import SentinelConnectionForm, { Props as SentinelConnectionFormProps } from 'uiSrc/pages/home/components/sentinel-connection/sentinel-connection-form/SentinelConnectionForm' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import SentinelConnectionWrapper, { Props, } from './SentinelConnectionWrapper' @@ -15,6 +16,11 @@ jest.mock('./sentinel-connection-form/SentinelConnectionForm', () => ({ default: jest.fn(), })) +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + const mockSentinelConnectionForm = (props: SentinelConnectionFormProps) => (
- - - - -
-) - -jest.mock('./KeyDetails/KeyDetails', () => ({ - __esModule: true, - namedExport: jest.fn(), - default: jest.fn(), -})) - -// jest.mock('uiSrc/slices/browser/hash') -// jest.mock('uiSrc/slices/browser/zset') -// jest.mock('uiSrc/slices/browser/string') -// jest.mock('uiSrc/slices/browser/set') -// jest.mock('uiSrc/slices/browser/list') -// jest.mock('uiSrc/slices/browser/keys') - -describe('KeyDetailsWrapper', () => { - beforeAll(() => { - KeyDetails.mockImplementation(MockKeyDetails) - }) - // beforeEach(() => { - // refreshHashFieldsAction.mockImplementation(() => jest.fn) - // refreshZsetMembersAction.mockImplementation(() => jest.fn) - // resetStringValue.mockImplementation(() => jest.fn) - // refreshSetMembersAction.mockImplementation(() => jest.fn) - // refreshListElementsAction.mockImplementation(() => jest.fn) - // deleteKeyAction.mockImplementation(() => jest.fn) - // editKey.mockImplementation(() => jest.fn) - // editKeyTTL.mockImplementation(() => jest.fn) - // fetchKeyInfo.mockImplementation(() => jest.fn) - // refreshKeyInfoAction.mockImplementation(() => jest.fn) - // selectedKeySelector.mockReturnValue('keyName') - // }) - it('should render', () => { - expect( - render() - ).toBeTruthy() - }) - - describe('should call onRefresh', () => { - test.each(Object.values(KeyTypes))('should call onRefresh', (keyType) => { - KeyDetails.mockImplementationOnce((props: KeyDetailsProps) => ( - - )) - const component = render() - fireEvent.click(screen.getByTestId('refresh-btn')) - expect(component).toBeTruthy() - }) - }) - - it('should call onDelete', () => { - const component = render() - fireEvent.click(screen.getByTestId('delete-btn')) - expect(component).toBeTruthy() - }) - - it('should call onClose', () => { - const onClose = jest.fn() - const component = render() - fireEvent.click(screen.getByTestId('close-btn')) - expect(component).toBeTruthy() - expect(onClose).toBeCalled() - }) - - it('should call onEditKey', () => { - const component = render() - fireEvent.click(screen.getByTestId('edit-key-btn')) - expect(component).toBeTruthy() - }) - - it('should call onEditTtl', () => { - const component = render() - fireEvent.click(screen.getByTestId('edit-ttl-btn')) - expect(component).toBeTruthy() - }) -}) diff --git a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx deleted file mode 100644 index 91da72d0ee..0000000000 --- a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { useEffect } from 'react' -import { isUndefined } from 'lodash' -import { useDispatch, useSelector } from 'react-redux' -import { useParams } from 'react-router-dom' - -import { - deleteSelectedKeyAction, - editKey, - editKeyTTL, - fetchKeyInfo, - keysSelector, - refreshKeyInfoAction, - selectedKeyDataSelector, - toggleBrowserFullScreen, -} from 'uiSrc/slices/browser/keys' -import { KeyTypes, ModulesKeyTypes, STRING_MAX_LENGTH } from 'uiSrc/constants' -import { refreshHashFieldsAction } from 'uiSrc/slices/browser/hash' -import { refreshZsetMembersAction } from 'uiSrc/slices/browser/zset' -import { fetchString, resetStringValue } from 'uiSrc/slices/browser/string' -import { refreshSetMembersAction } from 'uiSrc/slices/browser/set' -import { refreshListElementsAction } from 'uiSrc/slices/browser/list' -import { fetchReJSON } from 'uiSrc/slices/browser/rejson' -import { refreshStream } from 'uiSrc/slices/browser/stream' -import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { Nullable } from 'uiSrc/utils' -import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' -import KeyDetails from './KeyDetails/KeyDetails' - -export interface Props { - isFullScreen: boolean - arePanelsCollapsed: boolean - onToggleFullScreen: () => void - onCloseKey: () => void - onEditKey: (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => void - onRemoveKey: () => void - keyProp: RedisResponseBuffer | null - totalKeys: number - keysLastRefreshTime: Nullable -} - -const KeyDetailsWrapper = (props: Props) => { - const { - isFullScreen, - arePanelsCollapsed, - onToggleFullScreen, - onCloseKey, - onEditKey, - onRemoveKey, - keyProp, - totalKeys, - keysLastRefreshTime, - } = props - - const { instanceId } = useParams<{ instanceId: string }>() - const { viewType } = useSelector(keysSelector) - const { type: keyType, name: keyName, length: keyLength } = useSelector(selectedKeyDataSelector) ?? { - type: KeyTypes.String, - } - - const dispatch = useDispatch() - - useEffect(() => { - if (keyProp === null) { - return - } - // Restore key details from context in future - // (selectedKey.data?.name !== keyProp) - dispatch(fetchKeyInfo(keyProp)) - }, [keyProp]) - - useEffect(() => { - if (!isUndefined(keyName)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_VIEWED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_VIEWED - ), - eventData: { - keyType, - databaseId: instanceId, - length: keyLength, - } - }) - } - }, [keyName]) - - const handleDeleteKey = (key: RedisResponseBuffer, type: string) => { - dispatch(deleteSelectedKeyAction(key, - () => { - if (type === KeyTypes.String) { - dispatch(resetStringValue()) - } - onRemoveKey() - })) - } - - const handleRefreshKey = (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args: IFetchKeyArgs) => { - const resetData = false - dispatch(refreshKeyInfoAction(key)) - switch (type) { - case KeyTypes.Hash: { - dispatch(refreshHashFieldsAction(key, resetData)) - break - } - case KeyTypes.ZSet: { - dispatch(refreshZsetMembersAction(key, resetData)) - break - } - case KeyTypes.Set: { - dispatch(refreshSetMembersAction(key, resetData)) - break - } - case KeyTypes.List: { - dispatch(refreshListElementsAction(key, resetData)) - break - } - case KeyTypes.String: { - dispatch(fetchString(key, { resetData, end: args?.end || STRING_MAX_LENGTH })) - break - } - case KeyTypes.ReJSON: { - dispatch(fetchReJSON(key, '.', true)) - break - } - case KeyTypes.Stream: { - dispatch(refreshStream(key, resetData)) - break - } - default: - dispatch(fetchKeyInfo(key, resetData)) - } - } - - const handleEditTTL = (key: RedisResponseBuffer, ttl: number) => { - dispatch(editKeyTTL(key, ttl)) - } - const handleEditKey = (oldKey: RedisResponseBuffer, newKey: RedisResponseBuffer, onFailure?: () => void) => { - dispatch(editKey(oldKey, newKey, () => onEditKey(oldKey, newKey), onFailure)) - } - - const handleClose = () => { - onCloseKey() - } - - const handleClosePanel = () => { - dispatch(toggleBrowserFullScreen(true)) - keyProp && onCloseKey() - } - - return ( - - ) -} - -export default React.memo(KeyDetailsWrapper) diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx index 24102f588f..52739d3f95 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx @@ -20,9 +20,8 @@ import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiS import { OnboardingStepName, OnboardingSteps } from 'uiSrc/constants/onboarding' import { incrementOnboardStepAction } from 'uiSrc/slices/app/features' -import { OnboardingTour } from 'uiSrc/components' +import { AutoRefresh, OnboardingTour } from 'uiSrc/components' import { ONBOARDING_FEATURES } from 'uiSrc/components/onboarding-features' -import AutoRefresh from '../auto-refresh' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/browser/modules/index.ts b/redisinsight/ui/src/pages/browser/modules/index.ts new file mode 100644 index 0000000000..d9f9bbcc8f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/index.ts @@ -0,0 +1,2 @@ +export { KeyDetails } from './key-details' +export { KeyDetailsHeader } from './key-details-header' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx similarity index 81% rename from redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx index 495b4e26fb..d80029786d 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx @@ -3,7 +3,7 @@ import { mock } from 'ts-mockito' import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' import { stringDataSelector } from 'uiSrc/slices/browser/string' import { KeyTypes } from 'uiSrc/constants' -import KeyDetailsHeader, { Props } from './KeyDetailsHeader' +import { Props, KeyDetailsHeader } from './KeyDetailsHeader' const mockedProps = mock() @@ -71,29 +71,6 @@ describe('KeyDetailsHeader', () => { fireEvent.click(screen.getByLabelText(/Copy key name/i)) }) - it('should call onRefresh', () => { - const onRefresh = jest.fn() - render() - - fireEvent.click(screen.getByTestId('refresh-key-btn')) - expect(onRefresh).toBeCalled() - }) - - it('should call onEditKey', () => { - const onEditKey = jest.fn() - render() - - fireEvent.click(screen.getByTestId(KEY_BTN_TEST_ID)) - - fireEvent.change( - screen.getByTestId(KEY_INPUT_TEST_ID), - { target: { value: 'key' } } - ) - - fireEvent.click(screen.getByTestId('apply-btn')) - expect(onEditKey).toBeCalled() - }) - it('should change ttl properly', () => { render() @@ -138,4 +115,12 @@ describe('KeyDetailsHeader', () => { const editValueBtn = screen.getByTestId(`${EDIT_VALUE_BTN_TEST_ID}`) expect(editValueBtn).toHaveProperty('disabled', true) }) + + describe('should call onRefresh', () => { + test.each(Object.values(KeyTypes))('should call onRefresh', (keyType) => { + const component = render() + fireEvent.click(screen.getByTestId('refresh-key-btn')) + expect(component).toBeTruthy() + }) + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx new file mode 100644 index 0000000000..555fa588a9 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -0,0 +1,214 @@ +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiToolTip, +} from '@elastic/eui' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import AutoSizer from 'react-virtualized-auto-sizer' + +import { GroupBadge, AutoRefresh, FullScreen } from 'uiSrc/components' +import { + KeyTypes, + ModulesKeyTypes, +} from 'uiSrc/constants' +import { + deleteSelectedKeyAction, + editKey, + editKeyTTL, + initialKeyInfo, + keysSelector, + refreshKey, + selectedKeyDataSelector, + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { resetStringValue } from 'uiSrc/slices/browser/string' +import { KeyDetailsHeaderFormatter } from './components/key-details-header-formatter' +import { KeyDetailsHeaderName } from './components/key-details-header-name' +import { KeyDetailsHeaderTTL } from './components/key-details-header-ttl' +import { KeyDetailsHeaderActions } from './components/key-details-header-actions' +import { KeyDetailsHeaderDelete } from './components/key-details-header-delete' +import { KeyDetailsHeaderSizeLength } from './components/key-details-header-size-length' +import { HIDE_LAST_REFRESH } from './constants' + +import styles from './styles.module.scss' + +export interface Props { + keyType: KeyTypes | ModulesKeyTypes + onClose: (key: RedisResponseBuffer) => void + onRemoveKey: () => void + onEditKey: (key: RedisResponseBuffer, newKey: RedisResponseBuffer, onFailure?: () => void) => void + onAddItem?: () => void + onEditItem?: () => void + onRemoveItem?: () => void + isFullScreen: boolean + arePanelsCollapsed: boolean + onToggleFullScreen: () => void +} + +const KeyDetailsHeader = ({ + isFullScreen, + arePanelsCollapsed, + onToggleFullScreen = () => {}, + onClose, + onRemoveKey, + onEditKey, + keyType, + onAddItem = () => {}, + onEditItem = () => {}, + onRemoveItem = () => {}, +}: Props) => { + const { loading, lastRefreshTime } = useSelector(selectedKeySelector) + const { + type, + length, + nameString: keyProp, + name: keyBuffer, + } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { viewType } = useSelector(keysSelector) + + const dispatch = useDispatch() + + const handleRefreshKey = () => { + dispatch(refreshKey(keyBuffer!, type)) + } + + const handleEditTTL = (key: RedisResponseBuffer, ttl: number) => { + dispatch(editKeyTTL(key, ttl)) + } + const handleEditKey = (oldKey: RedisResponseBuffer, newKey: RedisResponseBuffer, onFailure?: () => void) => { + dispatch(editKey(oldKey, newKey, () => onEditKey(oldKey, newKey), onFailure)) + } + + const handleDeleteKey = (key: RedisResponseBuffer, type: string) => { + dispatch(deleteSelectedKeyAction(key, + () => { + if (type === KeyTypes.String) { + dispatch(resetStringValue()) + } + onRemoveKey() + })) + } + + const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { + const browserViewEvent = enableAutoRefresh + ? TelemetryEvent.BROWSER_KEY_DETAILS_AUTO_REFRESH_ENABLED + : TelemetryEvent.BROWSER_KEY_DETAILS_AUTO_REFRESH_DISABLED + const treeViewEvent = enableAutoRefresh + ? TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_DISABLED + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + length, + databaseId: instanceId, + keyType: type, + refreshRate: +refreshRate + } + }) + } + + const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => { + if (enableAutoRefresh) { + handleEnableAutoRefresh(enableAutoRefresh, refreshRate) + } + } + + return ( +
+ {loading ? ( +
+ +
+ ) : ( + + {({ width = 0 }) => ( +
+ + + + + + + {!arePanelsCollapsed && ( + + + + )} + + {(!arePanelsCollapsed || isFullScreen) && ( + + onClose(keyProp)} + data-testid="close-key-btn" + /> + + )} + + + + + + +
+ HIDE_LAST_REFRESH} + containerClassName={styles.actionBtn} + onRefresh={handleRefreshKey} + onEnableAutoRefresh={handleEnableAutoRefresh} + onChangeAutoRefreshRate={handleChangeAutoRefreshRate} + testid="refresh-key-btn" + /> + {Object.values(KeyTypes).includes(keyType as KeyTypes) && ( + + )} + {keyType && ( + + )} + +
+
+
+
+ )} +
+ )} +
+ ) +} + +export { KeyDetailsHeader } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx new file mode 100644 index 0000000000..c3b9ce968b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, KeyDetailsHeaderActions } from './KeyDetailsHeaderActions' + +const mockedProps = mock() + +describe('KeyDetailsHeaderActions', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx new file mode 100644 index 0000000000..45818dc3ea --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { + EuiButton, + EuiButtonIcon, + EuiToolTip, +} from '@elastic/eui' +import cx from 'classnames' +import { useSelector } from 'react-redux' + +import { + KEY_TYPES_ACTIONS, + KeyTypes, + ModulesKeyTypes, + STREAM_ADD_ACTION, + TEXT_DISABLED_COMPRESSED_VALUE, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_DISABLED_STRING_EDITING, +} from 'uiSrc/constants' +import { initialKeyInfo, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { streamSelector } from 'uiSrc/slices/browser/stream' +import { + Maybe, + isFormatEditable, + isFullStringLoaded, +} from 'uiSrc/utils' +import { stringDataSelector, stringSelector } from 'uiSrc/slices/browser/string' + +import { MIDDLE_SCREEN_RESOLUTION } from '../../constants' +import styles from './styles.module.scss' + +export interface Props { + keyType: KeyTypes | ModulesKeyTypes + width: Maybe + onAddItem?: () => void + onEditItem?: () => void + onRemoveItem?: () => void +} + +const KeyDetailsHeaderActions = ({ + width = 0, + keyType, + onAddItem = () => {}, + onEditItem = () => {}, + onRemoveItem = () => {}, +}: Props) => { + const { length } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + const { value: keyValue } = useSelector(stringDataSelector) + const { isCompressed: isStringCompressed } = useSelector(stringSelector) + const { viewType: streamViewType } = useSelector(streamSelector) + const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + + const isEditable = !isStringCompressed && isFormatEditable(viewFormatProp) + const isStringEditable = keyType === KeyTypes.String ? isFullStringLoaded(keyValue?.data?.length, length) : true + const noEditableText = isStringCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING + const editToolTip = !isEditable ? noEditableText : (!isStringEditable ? TEXT_DISABLED_STRING_EDITING : null) + + return ( + <> + {KEY_TYPES_ACTIONS[keyType] && 'addItems' in KEY_TYPES_ACTIONS[keyType] && ( + MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.iiu]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {KEY_TYPES_ACTIONS[keyType].addItems?.name} + + ) : ( + + )} + + + )} + {keyType === KeyTypes.Stream && ( + MIDDLE_SCREEN_RESOLUTION ? '' : STREAM_ADD_ACTION[streamViewType].name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {STREAM_ADD_ACTION[streamViewType].name} + + ) : ( + + )} + + + )} + {KEY_TYPES_ACTIONS[keyType] && 'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( + + + + )} + {KEY_TYPES_ACTIONS[keyType] && 'editItem' in KEY_TYPES_ACTIONS[keyType] && ( +
+ + + +
+ )} + + ) +} + +export { KeyDetailsHeaderActions } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts new file mode 100644 index 0000000000..84cdbd1c94 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeaderActions } from './KeyDetailsHeaderActions' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/styles.module.scss new file mode 100644 index 0000000000..52eb240997 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/styles.module.scss @@ -0,0 +1,12 @@ +.actionBtn { + margin-right: 12px; + position: relative; + z-index: 2; + + &.withText { + color: var(--euiTextSubduedColor) !important; + :global(.euiButton__text) { + font: normal normal normal 12px/18px Graphik !important; + } + } +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.spec.tsx new file mode 100644 index 0000000000..0706d8fb9a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, KeyDetailsHeaderDelete } from './KeyDetailsHeaderDelete' + +const mockedProps = mock() + +describe('KeyDetailsHeaderDelete', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx new file mode 100644 index 0000000000..d75ae8c159 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx @@ -0,0 +1,105 @@ +import { + EuiButton, + EuiButtonIcon, + EuiPopover, + EuiText, +} from '@elastic/eui' +import React, { useState } from 'react' +import { useSelector } from 'react-redux' + +import { initialKeyInfo, keysSelector, selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { + formatLongName, +} from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface Props { + onDelete: (key: RedisResponseBuffer, type: string) => void +} + +const KeyDetailsHeaderDelete = ({ + onDelete, +}: Props) => { + const { + type, + nameString: keyProp, + name: keyBuffer, + } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { viewType } = useSelector(keysSelector) + + const [isPopoverDeleteOpen, setIsPopoverDeleteOpen] = useState(false) + + const tooltipContent = formatLongName(keyProp || '') + + const closePopoverDelete = () => { + setIsPopoverDeleteOpen(false) + } + + const showPopoverDelete = () => { + setIsPopoverDeleteOpen((isPopoverDeleteOpen) => !isPopoverDeleteOpen) + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_DELETE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_DELETE_CLICKED + ), + eventData: { + databaseId: instanceId, + source: 'keyValue', + keyType: type + } + }) + } + + return ( + + )} + > +
+ +

+ {tooltipContent} +

+ + will be deleted. + +
+
+ onDelete(keyBuffer, type)} + className={styles.popoverDeleteBtn} + data-testid="delete-key-confirm-btn" + > + Delete + +
+
+
+ ) +} + +export { KeyDetailsHeaderDelete } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/index.ts new file mode 100644 index 0000000000..75dc9271f7 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeaderDelete } from './KeyDetailsHeaderDelete' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/styles.module.scss new file mode 100644 index 0000000000..3a8e0eee1f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/styles.module.scss @@ -0,0 +1,8 @@ +.popoverDeleteContainer { + overflow: hidden; + max-width: 350px !important; +} + +.popoverFooter { + margin-top: 10px; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/KeyValueFormatter.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx similarity index 77% rename from redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/KeyValueFormatter.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx index 2d53a1ac6c..41f250fd55 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/KeyValueFormatter.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx @@ -2,7 +2,7 @@ import React from 'react' import { mock } from 'ts-mockito' import { fireEvent, render, screen, waitForEuiPopoverVisible } from 'uiSrc/utils/test-utils' -import KeyValueFormatter, { Props } from './KeyValueFormatter' +import { Props, KeyDetailsHeaderFormatter } from './KeyDetailsHeaderFormatter' const mockedProps = { ...mock(), @@ -10,7 +10,7 @@ const mockedProps = { describe('KeyValueFormatter', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('should render options in the strict order', async () => { @@ -26,7 +26,7 @@ describe('KeyValueFormatter', () => { 'PHP serialized', 'Java serialized', ] - render() + render() fireEvent.click(screen.getByTestId('select-format-key-value')) diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/KeyValueFormatter.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx similarity index 96% rename from redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/KeyValueFormatter.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx index 049e58f92c..f41a9e1073 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/KeyValueFormatter.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx @@ -13,13 +13,13 @@ import FormattersDark from 'uiSrc/assets/img/icons/formatter_dark.svg' import { stringDataSelector } from 'uiSrc/slices/browser/string' import { isFullStringLoaded } from 'uiSrc/utils' import { getKeyValueFormatterOptions } from './constants' -import { MIDDLE_SCREEN_RESOLUTION } from '../../KeyDetailsHeader' +import { MIDDLE_SCREEN_RESOLUTION } from '../../constants' import styles from './styles.module.scss' export interface Props { width: number } -const KeyValueFormatter = (props: Props) => { +const KeyDetailsHeaderFormatter = (props: Props) => { const { width } = props const { instanceId = '' } = useParams<{ instanceId: string }>() @@ -114,4 +114,4 @@ const KeyValueFormatter = (props: Props) => { ) } -export default KeyValueFormatter +export { KeyDetailsHeaderFormatter } diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/constants.ts rename to redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/index.ts new file mode 100644 index 0000000000..4cc693fb9b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeaderFormatter } from './KeyDetailsHeaderFormatter' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-header/components/Formatter/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/KeyDetailsHeaderName.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/KeyDetailsHeaderName.spec.tsx new file mode 100644 index 0000000000..70d3cd3373 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/KeyDetailsHeaderName.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, KeyDetailsHeaderName } from './KeyDetailsHeaderName' + +const mockedProps = mock() + +describe('KeyDetailsHeaderName', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/KeyDetailsHeaderName.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/KeyDetailsHeaderName.tsx new file mode 100644 index 0000000000..09f99a0cae --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/KeyDetailsHeaderName.tsx @@ -0,0 +1,228 @@ +import { + EuiButtonIcon, + EuiFieldText, + EuiFlexGrid, + EuiFlexItem, + EuiIcon, + EuiText, + EuiToolTip, +} from '@elastic/eui' +import cx from 'classnames' +import { isNull } from 'lodash' +import React, { ChangeEvent, useEffect, useRef, useState } from 'react' +import { useSelector } from 'react-redux' + +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { + TEXT_UNPRINTABLE_CHARACTERS, +} from 'uiSrc/constants' +import { AddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { initialKeyInfo, keysSelector, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { + formatLongName, + isEqualBuffers, + replaceSpaces, + stringToBuffer, +} from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface Props { + onEditKey: (key: RedisResponseBuffer, newKey: RedisResponseBuffer, onFailure?: () => void) => void +} + +const COPY_KEY_NAME_ICON = 'copyKeyNameIcon' + +const KeyDetailsHeaderName = ({ + onEditKey, +}: Props) => { + const { loading } = useSelector(selectedKeySelector) + const { + ttl: ttlProp, + type, + nameString: keyProp, + name: keyBuffer, + } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { viewType } = useSelector(keysSelector) + + const [key, setKey] = useState(keyProp) + const [keyIsEditing, setKeyIsEditing] = useState(false) + const [keyIsHovering, setKeyIsHovering] = useState(false) + const [keyIsEditable, setKeyIsEditable] = useState(true) + + useEffect(() => { + setKey(keyProp) + setKeyIsEditable(isEqualBuffers(keyBuffer, stringToBuffer(keyProp || ''))) + }, [keyProp, ttlProp, keyBuffer]) + + const keyNameRef = useRef(null) + + const tooltipContent = formatLongName(keyProp || '') + + const onMouseEnterKey = () => { + setKeyIsHovering(true) + } + + const onMouseLeaveKey = () => { + setKeyIsHovering(false) + } + + const onClickKey = () => { + setKeyIsEditing(true) + } + + const onChangeKey = ({ currentTarget: { value } }: ChangeEvent) => { + keyIsEditing && setKey(value) + } + + const applyEditKey = () => { + setKeyIsEditing(false) + setKeyIsHovering(false) + + const newKeyBuffer = stringToBuffer(key || '') + + if (keyBuffer && !isEqualBuffers(keyBuffer, newKeyBuffer) && !isNull(keyProp)) { + onEditKey(keyBuffer, newKeyBuffer, () => setKey(keyProp)) + } + } + + const cancelEditKey = (event?: React.MouseEvent) => { + const { id } = event?.target as HTMLElement || {} + if (id === COPY_KEY_NAME_ICON) { + return + } + setKey(keyProp) + setKeyIsEditing(false) + setKeyIsHovering(false) + + event?.stopPropagation() + } + + const handleCopy = ( + event: any, + text = '', + keyInputIsEditing: boolean, + keyNameInputRef: React.RefObject + ) => { + navigator.clipboard.writeText(text) + + if (keyInputIsEditing) { + keyNameInputRef?.current?.focus() + } + + event.stopPropagation() + + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_COPIED, + TelemetryEvent.TREE_VIEW_KEY_COPIED + ), + eventData: { + databaseId: instanceId, + keyType: type + } + }) + } + + const appendKeyEditing = () => + (!keyIsEditing ? : '') + + return ( + + {(keyIsEditing || keyIsHovering) && ( + + + + <> + applyEditKey()} + isDisabled={!keyIsEditable} + disabledTooltipText={TEXT_UNPRINTABLE_CHARACTERS} + onDecline={(event) => cancelEditKey(event)} + viewChildrenMode={!keyIsEditing} + isLoading={loading} + declineOnUnmount={false} + > + + +

{key}

+ +
+ {keyIsHovering && ( + + + handleCopy(event, key!, keyIsEditing, keyNameRef)} + data-testid="copy-key-name-btn" + /> + + )} +
+
+ )} + + + {replaceSpaces(keyProp?.substring(0, 200))} + + +
+ + ) +} + +export { KeyDetailsHeaderName } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/index.ts new file mode 100644 index 0000000000..f5d9e23051 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeaderName } from './KeyDetailsHeaderName' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/styles.module.scss new file mode 100644 index 0000000000..72e267185d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-name/styles.module.scss @@ -0,0 +1,69 @@ +.classNameGridComponent { + position: relative; +} + +.flexItemKeyInput { + flex-direction: row !important; + width: 100% !important; +} + +.toolTipAnchorKey { + max-width: calc(100% - 25px); + height: 31px !important; +} + +.keyInput { + height: 31px !important; + font-size: 14px !important; + font-weight: 500 !important; +} + +:global(.browserPage .key-details-header .euiFormControlLayout) { + .keyInputEditing { + height: 31px !important; + } +} + +.keyHiddenText { + display: inline-block; + visibility: hidden; + height: 1px; + overflow: hidden; + max-width: 100%; + margin-right: 80px; + word-break: break-all; +} + +.copyKey { + position: absolute; + padding-left: 7px; + padding-top: 4px; + right: 0; + height: 31px; + width: 25px; +} + +.capitalize { + text-transform: capitalize; +} + +.key { + display: flex; + width: 100%; + min-width: 100%; + + padding-left: 9px; + line-height: 31px !important; +} + +.hidden { + display: none; +} + +.keyFlexItem { + overflow: hidden; +} + +.keyFlexItemEditing { + overflow: inherit; +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.spec.tsx new file mode 100644 index 0000000000..6d9471e304 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, KeyDetailsHeaderSizeLength } from './KeyDetailsHeaderSizeLength' + +const mockedProps = mock() + +describe('KeyDetailsHeaderSizeLength', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx new file mode 100644 index 0000000000..427e2c2606 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx @@ -0,0 +1,74 @@ +import { + EuiFlexItem, + EuiText, + EuiToolTip, +} from '@elastic/eui' +import React from 'react' +import { useSelector } from 'react-redux' + +import { LENGTH_NAMING_BY_TYPE } from 'uiSrc/constants' +import { initialKeyInfo, selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { formatBytes } from 'uiSrc/utils' + +import { MIDDLE_SCREEN_RESOLUTION } from '../../constants' +import styles from './styles.module.scss' + +export interface Props { + width: number +} + +const KeyDetailsHeaderSizeLength = ({ + width, +}: Props) => { + const { + type, + size, + length, + } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + + return ( + <> + {size && ( + + + + {formatBytes(size, 3)} + + )} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION && 'Key Size: '} + {formatBytes(size, 0)} + + + + + )} + + + {LENGTH_NAMING_BY_TYPE[type] ?? 'Length'} + {': '} + {length ?? '-'} + + + + ) +} + +export { KeyDetailsHeaderSizeLength } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/index.ts new file mode 100644 index 0000000000..36681672cd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeaderSizeLength } from './KeyDetailsHeaderSizeLength' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/styles.module.scss new file mode 100644 index 0000000000..327aba3dbf --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/styles.module.scss @@ -0,0 +1,4 @@ +.subtitleText { + padding: 6px 2px 6px 0; +} + diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/KeyDetailsHeaderTTL.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/KeyDetailsHeaderTTL.spec.tsx new file mode 100644 index 0000000000..81e9f7aa66 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/KeyDetailsHeaderTTL.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, KeyDetailsHeaderTTL } from './KeyDetailsHeaderTTL' + +const mockedProps = mock() + +describe('KeyDetailsHeaderTTL', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/KeyDetailsHeaderTTL.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/KeyDetailsHeaderTTL.tsx new file mode 100644 index 0000000000..dd7cf1baf6 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/KeyDetailsHeaderTTL.tsx @@ -0,0 +1,160 @@ +import { + EuiFieldText, + EuiFlexGrid, + EuiFlexItem, + EuiIcon, + EuiText, +} from '@elastic/eui' +import cx from 'classnames' +import React, { ChangeEvent, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { initialKeyInfo, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { + + MAX_TTL_NUMBER, + validateTTLNumber +} from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface Props { + onEditTTL: (key: RedisResponseBuffer, ttl: number) => void +} + +const KeyDetailsHeaderTTL = ({ + onEditTTL, +}: Props) => { + const { loading } = useSelector(selectedKeySelector) + const { + ttl: ttlProp, + nameString: keyProp, + name: keyBuffer, + } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + + const [ttl, setTTL] = useState(`${ttlProp}`) + const [ttlIsEditing, setTTLIsEditing] = useState(false) + const [ttlIsHovering, setTTLIsHovering] = useState(false) + + useEffect(() => { + setTTL(`${ttlProp}`) + }, [keyProp, ttlProp, keyBuffer]) + + const onMouseEnterTTL = () => { + setTTLIsHovering(true) + } + + const onMouseLeaveTTL = () => { + setTTLIsHovering(false) + } + + const onClickTTL = () => { + setTTLIsEditing(true) + } + + const onChangeTtl = ({ currentTarget: { value } }: ChangeEvent) => { + ttlIsEditing && setTTL(validateTTLNumber(value) || '-1') + } + + const applyEditTTL = () => { + const ttlValue = ttl || '-1' + + setTTLIsEditing(false) + setTTLIsHovering(false) + + if (`${ttlProp}` !== ttlValue && keyBuffer) { + onEditTTL(keyBuffer, +ttlValue) + } + } + + const cancelEditTTl = (event: any) => { + setTTL(`${ttlProp}`) + setTTLIsEditing(false) + setTTLIsHovering(false) + + event?.stopPropagation() + } + + const appendTTLEditing = () => + (!ttlIsEditing ? : '') + + return ( + + + <> + {(ttlIsEditing || ttlIsHovering) && ( + + + + TTL: + + + + applyEditTTL()} + onDecline={(event) => cancelEditTTl(event)} + viewChildrenMode={!ttlIsEditing} + isLoading={loading} + declineOnUnmount={false} + > + + + + + )} + + TTL: + + {ttl === '-1' ? 'No limit' : ttl} + + + + + ) +} + +export { KeyDetailsHeaderTTL } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/index.ts new file mode 100644 index 0000000000..1917ea4162 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeaderTTL } from './KeyDetailsHeaderTTL' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/styles.module.scss new file mode 100644 index 0000000000..64aa90f993 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-ttl/styles.module.scss @@ -0,0 +1,43 @@ +.subtitleText { + padding: 6px 2px 6px 0; +} + +.controlsKey { + right: 25px; +} + + + +.cancelEditBtn:hover { + color: var(--euiColorColorDanger) !important; +} + +.applyEditBtn:hover { + color: var(--euiColorSecondary) !important; +} + +.flexItemTTL { + width: 152px; + min-width: 152px; +} + +.ttlInput { + min-width: 106px; + font-size: 13px !important; + &.editing { + width: 124px; + } +} + +.ttlGridComponent, +.classNameGridComponent { + position: relative; +} + +.hidden { + display: none; +} + +.ttlTextValue { + padding-left: 11px; +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts new file mode 100644 index 0000000000..0be943b3fe --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts @@ -0,0 +1 @@ +export * from './resolutions' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/resolutions.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/resolutions.ts new file mode 100644 index 0000000000..5949905914 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/resolutions.ts @@ -0,0 +1,3 @@ +const PADDING_WRAPPER_SIZE = 36 +export const HIDE_LAST_REFRESH = 850 - PADDING_WRAPPER_SIZE +export const MIDDLE_SCREEN_RESOLUTION = 740 - PADDING_WRAPPER_SIZE diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts new file mode 100644 index 0000000000..1e52d0f3ec --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts @@ -0,0 +1 @@ +export { KeyDetailsHeader } from './KeyDetailsHeader' \ No newline at end of file diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details-header/styles.module.scss new file mode 100644 index 0000000000..8ef5f5b697 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/styles.module.scss @@ -0,0 +1,64 @@ +:global { + .browserPage { + .key-details-header { + .euiFieldText--compressed, + .euiFormControlLayout--compressed { + height: 29px !important; + } + + .euiFormControlLayout { + width: 100%; + max-width: 100%; + + &.euiFormControlLayout--readOnly { + border: 1px solid var(--controlsBorderColor); + cursor: auto; + } + + input { + height: 29px !important; + cursor: pointer; + max-width: none; + font-family: 'Graphik', sans-serif !important; + } + } + } + } +} + + +.container { + min-height: 108px; + padding: 18px 18px 12px 18px; + border-bottom: 1px solid var(--euiColorLightShade); + min-width: 100%; + position: relative; +} + +.closeBtn { + padding-top: 0 !important; + + svg { + width: 20px; + height: 20px; + } +} + +.groupSecondLine { + margin-top: 4px !important; +} + + +.subtitleActionBtns { + display: flex; + justify-content: flex-end; + align-items: center; + right: 13px; +} + + +.actionBtn { + margin-right: 12px; + position: relative; + z-index: 2; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.spec.tsx similarity index 90% rename from redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.spec.tsx index 631bc4f69a..0497edf349 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.spec.tsx @@ -1,9 +1,10 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render, screen } from 'uiSrc/utils/test-utils' -import KeyDetails, { Props } from './KeyDetails' -const mockedProps = mock() +import KeyDetails, { Props as KeyDetailsProps } from './KeyDetails' + +const mockedProps = mock() describe('KeyDetails', () => { it('should render', () => { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx new file mode 100644 index 0000000000..1cc01adb87 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from 'react' +import { isNull, isUndefined } from 'lodash' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import cx from 'classnames' + +import { + fetchKeyInfo, + keysSelector, + selectedKeyDataSelector, + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' + +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { Nullable } from 'uiSrc/utils' +import { KeyDetailsHeader } from 'uiSrc/pages/browser/modules' +import { streamSelector } from 'uiSrc/slices/browser/stream' +import { NoKeySelected } from './components/no-key-selected' +import { DynamicTypeDetails } from './components/dynamic-type-details' +import { AddItemsPanel } from './components/add-items-panel' +import { RemoveListElements } from './components/key-details-remove-items' + +import styles from './styles.module.scss' + +export interface Props { + isFullScreen: boolean + arePanelsCollapsed: boolean + onToggleFullScreen: () => void + onCloseKey: () => void + onEditKey: (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => void + onRemoveKey: () => void + keyProp: RedisResponseBuffer | null + totalKeys: number + keysLastRefreshTime: Nullable +} + +const KeyDetails = (props: Props) => { + const { + onCloseKey, + onEditKey, + onRemoveKey, + keyProp, + totalKeys, + keysLastRefreshTime, + } = props + + const { instanceId } = useParams<{ instanceId: string }>() + const { viewType } = useSelector(keysSelector) + const { loading, error = '', data } = useSelector(selectedKeySelector) + const isKeySelected = !isNull(useSelector(selectedKeyDataSelector)) + const { viewType: streamViewType } = useSelector(streamSelector) + const { type: keyType, name: keyName, length: keyLength } = useSelector(selectedKeyDataSelector) ?? { + type: KeyTypes.String, + } + + const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) + const [isRemoveItemPanelOpen, setIsRemoveItemPanelOpen] = useState(false) + const [editItem, setEditItem] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + if (keyProp === null) { + return + } + // Restore key details from context in future + // (selectedKey.data?.name !== keyProp) + dispatch(fetchKeyInfo(keyProp)) + }, [keyProp]) + + useEffect(() => { + if (!isUndefined(keyName)) { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_VIEWED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_VIEWED + ), + eventData: { + keyType, + databaseId: instanceId, + length: keyLength, + } + }) + } + }, [keyName]) + + const openAddItemPanel = () => { + setIsRemoveItemPanelOpen(false) + setIsAddItemPanelOpen(true) + if (!STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_ADD_VALUE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType + } + }) + } + } + + const openRemoveItemPanel = () => { + setIsAddItemPanelOpen(false) + setIsRemoveItemPanelOpen(true) + } + + const closeAddItemPanel = (isCancelled?: boolean) => { + if (isCancelled && isAddItemPanelOpen && !STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_ADD_VALUE_CANCELLED, + TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CANCELLED, + ), + eventData: { + databaseId: instanceId, + keyType, + } + }) + } + setIsAddItemPanelOpen(false) + } + + const closeRemoveItemPanel = () => { + setIsRemoveItemPanelOpen(false) + } + + return ( +
+
+ {!isKeySelected && !loading ? ( + + ) : ( +
+ setEditItem(!editItem)} + onRemoveKey={onRemoveKey} + onClose={onCloseKey} + onEditKey={onEditKey} + /> +
+ {!loading && ( +
+ +
+ )} + {isAddItemPanelOpen && ( + + )} + {isRemoveItemPanelOpen && ( +
+ {keyType === KeyTypes.List && ( + + )} +
+ )} +
+
+ )} +
+
+ ) +} + +export default React.memo(KeyDetails) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx new file mode 100644 index 0000000000..e0ea9c3e0c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, AddItemsPanel } from './AddItemsPanel' + +const mockedProps = mock() + +describe('AddItemsPanel', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx new file mode 100644 index 0000000000..79a83d98de --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import cx from 'classnames' +import { KeyTypes, ModulesKeyTypes, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' +import { + AddHashFields, + AddListElements, + AddSetMembers, + AddStreamEntries, + AddStreamGroup, + AddZsetMembers +} from '../key-details-add-items' + +import styles from './styles.module.scss' + +export interface Props { + selectedKeyType: KeyTypes | ModulesKeyTypes + streamViewType: StreamViewType + closeAddItemPanel: (isCancelled?: boolean) => void +} + +const AddItemsPanel = (props: Props) => { + const { + selectedKeyType, + streamViewType, + closeAddItemPanel, + } = props + + return ( +
+ {selectedKeyType === KeyTypes.Hash && ( + + )} + {selectedKeyType === KeyTypes.ZSet && ( + + )} + {selectedKeyType === KeyTypes.Set && ( + + )} + {selectedKeyType === KeyTypes.List && ( + + )} + {selectedKeyType === KeyTypes.Stream && ( + <> + {streamViewType === StreamViewType.Data && ( + + )} + {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType) && ( + + )} + + )} +
+ ) +} + +export { AddItemsPanel } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts new file mode 100644 index 0000000000..f17a51bb6b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts @@ -0,0 +1 @@ +export { AddItemsPanel } from './AddItemsPanel' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss new file mode 100644 index 0000000000..0838dd17dc --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss @@ -0,0 +1,4 @@ +.contentActive { + border-color: var(--euiColorPrimary) !important; + border-bottom-width: 1px !important; +} \ No newline at end of file diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx new file mode 100644 index 0000000000..abc031a050 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, DynamicTypeDetails } from './DynamicTypeDetails' + +const mockedProps = mock() + +describe('DynamicTypeDetails', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx new file mode 100644 index 0000000000..74ca924d86 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { useDispatch } from 'react-redux' +import { KeyTypes, MODULES_KEY_TYPES_NAMES, ModulesKeyTypes } from 'uiSrc/constants' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' +import { refreshKey } from 'uiSrc/slices/browser/keys' +import ZSetDetails from '../zset-details/ZSetDetails' +import SetDetails from '../set-details/SetDetails' +import StringDetails from '../string-details/StringDetails' +import HashDetails from '../hash-details/HashDetails' +import ListDetails from '../list-details/ListDetails' +import RejsonDetailsWrapper from '../rejson-details/RejsonDetailsWrapper' +import StreamDetailsWrapper from '../stream-details' +import ModulesTypeDetails from '../modules-type-details/ModulesTypeDetails' +import UnsupportedTypeDetails from '../unsupported-type-details/UnsupportedTypeDetails' + +export interface Props { + selectedKeyType: KeyTypes | ModulesKeyTypes + isAddItemPanelOpen: boolean + isRemoveItemPanelOpen: boolean + editItem: boolean + onRemoveKey: ()=> void + setEditItem: (isEdit: boolean) => void +} + +const DynamicTypeDetails = (props: Props) => { + const { + selectedKeyType, + isAddItemPanelOpen, + onRemoveKey, + isRemoveItemPanelOpen, + editItem, + setEditItem, + } = props + + const dispatch = useDispatch() + + const handleRefreshKey = (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args: IFetchKeyArgs) => { + dispatch(refreshKey(key, type, args)) + } + + const TypeDetails: any = { + [KeyTypes.ZSet]: , + [KeyTypes.Set]: , + [KeyTypes.String]: ( + setEditItem(isEdit)} + onRefresh={handleRefreshKey} + /> + ), + [KeyTypes.Hash]: , + [KeyTypes.List]: , + [KeyTypes.ReJSON]: , + [KeyTypes.Stream]: , + } + + // Supported key type + if (selectedKeyType && selectedKeyType in TypeDetails) { + return TypeDetails[selectedKeyType] + } + + // Unsupported redis modules key type + if (Object.values(ModulesKeyTypes).includes(selectedKeyType as ModulesKeyTypes)) { + return + } + + // Unsupported key type + if (Object.values(KeyTypes).includes(selectedKeyType as KeyTypes)) { + return + } + + return null +} + +export { DynamicTypeDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/index.ts new file mode 100644 index 0000000000..ab0fa34c7e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/index.ts @@ -0,0 +1 @@ +export { DynamicTypeDetails } from './DynamicTypeDetails' diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx similarity index 99% rename from redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx index 2401ca6096..fe5273d815 100644 --- a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx @@ -58,7 +58,7 @@ import { stringToBuffer } from 'uiSrc/utils/formatters/bufferFormatters' import { decompressingBuffer } from 'uiSrc/utils/decompressors' import { AddFieldsToHashDto, GetHashFieldsResponse, HashFieldDto, } from 'apiSrc/modules/browser/dto/hash.dto' -import PopoverDelete from '../popover-delete/PopoverDelete' +import PopoverDelete from '../../../../components/popover-delete/PopoverDelete' import styles from './styles.module.scss' const suffix = '_hash' diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/hash-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.tsx similarity index 99% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.tsx index deb8e5acc0..8aa1694cbd 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.tsx @@ -188,7 +188,6 @@ const AddHashFields = (props: Props) => { clearItemValues={clearFieldsValues} clearIsDisabled={isClearDisabled(item)} loading={loading} - anchorClassName={styles.refreshKeyTooltip} /> diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx index f9641fa108..9022fe9523 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx @@ -21,7 +21,7 @@ import { KeyTypes } from 'uiSrc/constants' import { stringToBuffer } from 'uiSrc/utils' import { PushElementToListDto } from 'apiSrc/modules/browser/dto' -import { AddListFormConfig as config } from '../../add-key/constants/fields-config' +import { AddListFormConfig as config } from '../../../../../components/add-key/constants/fields-config' import styles from '../styles.module.scss' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx similarity index 96% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx index 6a0944ef8d..b2372d4fb4 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx @@ -18,8 +18,8 @@ import { KeyTypes } from 'uiSrc/constants' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { stringToBuffer } from 'uiSrc/utils' -import AddItemsActions from '../../add-items-actions/AddItemsActions' -import { AddZsetFormConfig as config } from '../../add-key/constants/fields-config' +import AddItemsActions from '../../../../../components/add-items-actions/AddItemsActions' +import { AddZsetFormConfig as config } from '../../../../../components/add-key/constants/fields-config' import styles from '../styles.module.scss' @@ -159,7 +159,6 @@ const AddSetMembers = (props: Props) => { clearIsDisabled={isClearDisabled(item)} clearItemValues={clearMemberValues} loading={loading} - anchorClassName={styles.refreshKeyTooltip} /> diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx index e5ce425e6a..2241adeaea 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx @@ -9,7 +9,7 @@ import { addNewEntriesAction, streamDataSelector } from 'uiSrc/slices/browser/st import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { AddStreamFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' import { INITIAL_STREAM_FIELD_STATE } from 'uiSrc/pages/browser/components/add-key/AddKeyStream/AddKeyStream' -import { StreamEntryFields } from 'uiSrc/pages/browser/components/key-details-add-items' +import { StreamEntryFields } from 'uiSrc/pages/browser/modules/key-details/components/key-details-add-items' import { KeyTypes } from 'uiSrc/constants' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { AddStreamEntriesDto } from 'apiSrc/modules/browser/dto/stream.dto' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx similarity index 99% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx index b4158e15e2..51e95ac0b7 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx @@ -206,7 +206,6 @@ const StreamEntryFields = (props: Props) => { removeItem={removeField} clearItemValues={clearFieldsValues} clearIsDisabled={isClearDisabled(item)} - anchorClassName={styles.refreshKeyTooltip} /> diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx index 02debb3930..21f6505405 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx @@ -21,8 +21,8 @@ import { updateZsetScoreStateSelector, } from 'uiSrc/slices/browser/zset' -import AddItemsActions from '../../add-items-actions/AddItemsActions' -import { AddZsetFormConfig as config } from '../../add-key/constants/fields-config' +import AddItemsActions from '../../../../../components/add-items-actions/AddItemsActions' +import { AddZsetFormConfig as config } from '../../../../../components/add-key/constants/fields-config' import styles from '../styles.module.scss' @@ -228,7 +228,6 @@ const AddZsetMembers = (props: Props) => { addItemIsDisabled={members.some((item) => !item.score.length)} clearItemValues={clearMemberValues} loading={loading} - anchorClassName={styles.refreshKeyTooltip} /> diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts new file mode 100644 index 0000000000..5d97ea6979 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts @@ -0,0 +1,25 @@ +import AddHashFields, { INITIAL_HASH_FIELD_STATE, } from './add-hash-fields/AddHashFields' +import type { IHashFieldState } from './add-hash-fields/AddHashFields' +import AddListElements from './add-list-elements/AddListElements' +import AddSetMembers, { INITIAL_SET_MEMBER_STATE } from './add-set-members/AddSetMembers' +import type { ISetMemberState } from './add-set-members/AddSetMembers' +import AddStreamEntries, { StreamEntryFields } from './add-stream-entity' +import AddStreamGroup from './add-stream-group' +import AddZsetMembers, { INITIAL_ZSET_MEMBER_STATE } from './add-zset-members/AddZsetMembers' +import type { IZsetMemberState } from './add-zset-members/AddZsetMembers' + +export { + AddHashFields, + AddListElements, + AddSetMembers, + AddStreamEntries, + StreamEntryFields, + AddZsetMembers, + AddStreamGroup, + INITIAL_HASH_FIELD_STATE, + INITIAL_SET_MEMBER_STATE, + INITIAL_ZSET_MEMBER_STATE, + IHashFieldState, + ISetMemberState, + IZsetMemberState, +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-add-items/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts new file mode 100644 index 0000000000..5f5c9ba2f4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts @@ -0,0 +1 @@ +export { RemoveListElements } from './remove-list-elements/RemoveListElements' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx index 35137751dd..e9ed669459 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx @@ -4,7 +4,7 @@ import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' -import RemoveListElements, { Props } from './RemoveListElements' +import { Props, RemoveListElements } from './RemoveListElements' import { HEAD_DESTINATION } from '../../key-details-add-items/add-list-elements/AddListElements' const COUNT_INPUT = 'count-input' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx similarity index 98% rename from redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx index b882b223a4..324bad203a 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx @@ -30,7 +30,7 @@ import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'ui import { DeleteListElementsDto } from 'apiSrc/modules/browser/dto' -import { AddListFormConfig as config } from '../../add-key/constants/fields-config' +import { AddListFormConfig as config } from '../../../../../components/add-key/constants/fields-config' import { TAIL_DESTINATION, HEAD_DESTINATION, @@ -294,4 +294,4 @@ const RemoveListElements = (props: Props) => { ) } -export default RemoveListElements +export { RemoveListElements } diff --git a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/key-details-remove-items/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx diff --git a/redisinsight/ui/src/pages/browser/components/list-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/list-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/modules-type-details/ModulesTypeDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/modules-type-details/ModulesTypeDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/modules-type-details/ModulesTypeDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/modules-type-details/ModulesTypeDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/modules-type-details/ModulesTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/modules-type-details/ModulesTypeDetails.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/modules-type-details/ModulesTypeDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/modules-type-details/ModulesTypeDetails.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/NoKeySelected.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/NoKeySelected.spec.tsx new file mode 100644 index 0000000000..ba91fc110e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/NoKeySelected.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, NoKeySelected } from './NoKeySelected' + +const mockedProps = mock() + +describe('NoKeySelected', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/NoKeySelected.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/NoKeySelected.tsx new file mode 100644 index 0000000000..2c03170623 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/NoKeySelected.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui' +import { useDispatch } from 'react-redux' +import ExploreGuides from 'uiSrc/components/explore-guides' +import { Nullable } from 'uiSrc/utils' + +import { toggleBrowserFullScreen } from 'uiSrc/slices/browser/keys' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import styles from './styles.module.scss' + +export interface Props { + keyProp: Nullable + totalKeys: number + onClosePanel: () => void + keysLastRefreshTime: Nullable + error?: string +} + +export const NoKeySelected = (props: Props) => { + const { + keyProp, + totalKeys, + onClosePanel, + error, + keysLastRefreshTime, + } = props + + const dispatch = useDispatch() + + const handleClosePanel = () => { + dispatch(toggleBrowserFullScreen(true)) + keyProp && onClosePanel() + } + + const NoKeysSelectedMessage = () => ( + <> + {totalKeys > 0 ? ( + + Select the key from the list on the left to see the details of the key. + + ) : ()} + + ) + + return ( + <> + + + + +
+ + {error ? ( +

+ {error} +

+ ) : (!!keysLastRefreshTime && )} +
+
+ + ) +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/index.ts new file mode 100644 index 0000000000..1980db48fc --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/index.ts @@ -0,0 +1 @@ +export { NoKeySelected } from './NoKeySelected' \ No newline at end of file diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/styles.module.scss new file mode 100644 index 0000000000..21be3c5580 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/no-key-selected/styles.module.scss @@ -0,0 +1,21 @@ +.closeRightPanel { + position: absolute; + top: 22px; + right: 18px; + + .closeBtn { + :global(svg) { + width: 20px; + height: 20px; + } + } +} + +.placeholder { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 12px; + width: 100%; +} diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONArray/JSONArray.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONArray/JSONArray.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONArray/JSONArray.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONArray/JSONArray.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONInterfaces.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONInterfaces.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONInterfaces.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONInterfaces.ts diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONObject/JSONObject.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONObject/JSONObject.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONObject/JSONObject.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONObject/JSONObject.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONScalar/JSONScalar.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONScalar/JSONScalar.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONScalar/JSONScalar.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONScalar/JSONScalar.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.spec.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONUtils/JSONUtils.spec.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.spec.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONUtils/JSONUtils.spec.ts diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONUtils/JSONUtils.ts similarity index 88% rename from redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONUtils/JSONUtils.ts index 6b3d9acda1..9363afa3be 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/JSONUtils/JSONUtils.ts @@ -1,5 +1,5 @@ -import styles from 'uiSrc/pages/browser/components/rejson-details/styles.module.scss' import { JSONScalarValue } from '../JSONInterfaces' +import styles from '../styles.module.scss' enum ClassNames { string = 'jsonString', diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetails/RejsonDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetails/RejsonDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetails/RejsonDetails.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetails/RejsonDetails.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/constants.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/constants.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/constants.ts diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/styles.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/styles.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/rejson-details/styles.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/styles.scss diff --git a/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx diff --git a/redisinsight/ui/src/pages/browser/components/set-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/set-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/constants.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/constants.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/constants.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersView/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersViewWrapper.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/ConsumersViewWrapper.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/consumers-view/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/GroupsView.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/GroupsView.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsView/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsViewWrapper.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/GroupsViewWrapper.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/groups-view/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/groups-view/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/index.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/index.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/index.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/index.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageAckPopover/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/index.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/index.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/index.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/index.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/MessagesView.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/MessagesView.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesView/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesViewWrapper.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/MessagesViewWrapper.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/messages-view/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/messages-view/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataView/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-data-view/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/StreamTabs.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/StreamTabs.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/StreamTabs.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/StreamTabs.tsx diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/index.ts diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-tabs/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/stream-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx diff --git a/redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/unsupported-type-details/UnsupportedTypeDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/unsupported-type-details/UnsupportedTypeDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/unsupported-type-details/UnsupportedTypeDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/unsupported-type-details/UnsupportedTypeDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/unsupported-type-details/UnsupportedTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/unsupported-type-details/UnsupportedTypeDetails.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/unsupported-type-details/UnsupportedTypeDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/unsupported-type-details/UnsupportedTypeDetails.tsx diff --git a/redisinsight/ui/src/pages/browser/components/unsupported-type-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/unsupported-type-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/unsupported-type-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/unsupported-type-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx similarity index 99% rename from redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx index 790de07e6f..c9b51320c2 100644 --- a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx @@ -42,7 +42,7 @@ import { StopPropagation } from 'uiSrc/components/virtual-table' import { getColumnWidth } from 'uiSrc/components/virtual-grid' import { decompressingBuffer } from 'uiSrc/utils/decompressors' import { AddMembersToZSetDto, SearchZSetMembersResponse } from 'apiSrc/modules/browser/dto' -import PopoverDelete from '../popover-delete/PopoverDelete' +import PopoverDelete from '../../../../components/popover-delete/PopoverDelete' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/components/zset-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/index.ts new file mode 100644 index 0000000000..2c7e1334c7 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/index.ts @@ -0,0 +1,5 @@ +import KeyDetails from './KeyDetails' + +export { + KeyDetails, +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss similarity index 71% rename from redisinsight/ui/src/pages/browser/components/key-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss index 97d78b6108..6f939d4cfb 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss @@ -1,4 +1,4 @@ -.page { +.container { height: 100%; display: flex; flex-direction: column; @@ -16,7 +16,7 @@ } .content { - height: calc(100% - 220px); + height: 100%; background-color: var(--euiColorEmptyShade); position: relative; > div { @@ -33,15 +33,6 @@ border-bottom-width: 1px !important; } -.placeholder { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - padding: 12px; - width: 100%; -} - :global(.key-details-body) { position: relative; height: calc(100% - 105px); @@ -54,15 +45,3 @@ border-top: 1px solid var(--euiColorLightShade); } -.closeRightPanel { - position: absolute; - top: 22px; - right: 18px; - - .closeBtn { - :global(svg) { - width: 20px; - height: 20px; - } - } -} diff --git a/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx index 431ed26fe1..b43c928586 100644 --- a/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx +++ b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx @@ -16,7 +16,7 @@ import { useParams } from 'react-router-dom' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { DurationUnits } from 'uiSrc/constants' import { slowLogSelector } from 'uiSrc/slices/analytics/slowlog' -import AutoRefresh from 'uiSrc/pages/browser/components/auto-refresh' +import { AutoRefresh } from 'uiSrc/components' import { Nullable } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import SlowLogConfig from '../SlowLogConfig' diff --git a/redisinsight/ui/src/pages/triggeredFunctions/pages/Functions/components/FunctionsList/FunctionsList.tsx b/redisinsight/ui/src/pages/triggeredFunctions/pages/Functions/components/FunctionsList/FunctionsList.tsx index 5ed3a800a0..2394ff11f3 100644 --- a/redisinsight/ui/src/pages/triggeredFunctions/pages/Functions/components/FunctionsList/FunctionsList.tsx +++ b/redisinsight/ui/src/pages/triggeredFunctions/pages/Functions/components/FunctionsList/FunctionsList.tsx @@ -5,10 +5,10 @@ import cx from 'classnames' import { useParams } from 'react-router-dom' import { isEqual, pick } from 'lodash' import { Maybe, Nullable } from 'uiSrc/utils' -import AutoRefresh from 'uiSrc/pages/browser/components/auto-refresh' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { FunctionType, TriggeredFunctionsFunction } from 'uiSrc/slices/interfaces/triggeredFunctions' import { LIST_OF_FUNCTION_NAMES } from 'uiSrc/pages/triggeredFunctions/constants' +import { AutoRefresh } from 'uiSrc/components' import styles from './styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibrariesList/LibrariesList.tsx b/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibrariesList/LibrariesList.tsx index 122f41e8c3..8b253284fc 100644 --- a/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibrariesList/LibrariesList.tsx +++ b/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibrariesList/LibrariesList.tsx @@ -10,10 +10,10 @@ import cx from 'classnames' import { useParams } from 'react-router-dom' import { Maybe, Nullable, formatLongName } from 'uiSrc/utils' -import AutoRefresh from 'uiSrc/pages/browser/components/auto-refresh' import DeleteLibraryButton from 'uiSrc/pages/triggeredFunctions/pages/Libraries/components/DeleteLibrary' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { TriggeredFunctionsLibrary } from 'uiSrc/slices/interfaces/triggeredFunctions' +import { AutoRefresh } from 'uiSrc/components' import styles from './styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibraryDetails/LibraryDetails.tsx b/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibraryDetails/LibraryDetails.tsx index 1e70bf4e2f..6a57a65909 100644 --- a/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibraryDetails/LibraryDetails.tsx +++ b/redisinsight/ui/src/pages/triggeredFunctions/pages/Libraries/components/LibraryDetails/LibraryDetails.tsx @@ -29,7 +29,6 @@ import DeleteLibraryButton from 'uiSrc/pages/triggeredFunctions/pages/Libraries/ import { reSerializeJSON } from 'uiSrc/utils/formatters/json' import { FunctionType } from 'uiSrc/slices/interfaces/triggeredFunctions' -import AutoRefresh from 'uiSrc/pages/browser/components/auto-refresh' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { formatLongName, Nullable } from 'uiSrc/utils' @@ -40,6 +39,7 @@ import { } from 'uiSrc/pages/triggeredFunctions/constants' import { Pages } from 'uiSrc/constants' import { getFunctionsLengthByType } from 'uiSrc/utils/triggered-functions/utils' +import { AutoRefresh } from 'uiSrc/components' import styles from './styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/slices/browser/keys.ts b/redisinsight/ui/src/slices/browser/keys.ts index f08468fc43..6c09ae4572 100644 --- a/redisinsight/ui/src/slices/browser/keys.ts +++ b/redisinsight/ui/src/slices/browser/keys.ts @@ -11,7 +11,8 @@ import { ENDPOINT_BASED_ON_KEY_TYPE, SearchHistoryMode, SortOrder, - STRING_MAX_LENGTH + STRING_MAX_LENGTH, + ModulesKeyTypes } from 'uiSrc/constants' import { getApiErrorMessage, @@ -28,7 +29,7 @@ import { import { DEFAULT_SEARCH_MATCH, SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent, getAdditionalAddedEventData, getMatchType } from 'uiSrc/telemetry' import successMessages from 'uiSrc/components/notifications/success-messages' -import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { IFetchKeyArgs, IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { resetBrowserTree } from 'uiSrc/slices/app/context' import { @@ -44,12 +45,12 @@ import { import { CreateStreamDto } from 'apiSrc/modules/browser/dto/stream.dto' import { fetchString } from './string' -import { setZsetInitialState, fetchZSetMembers } from './zset' -import { fetchSetMembers } from './set' +import { setZsetInitialState, fetchZSetMembers, refreshZsetMembersAction } from './zset' +import { fetchSetMembers, refreshSetMembersAction } from './set' import { fetchReJSON } from './rejson' -import { setHashInitialState, fetchHashFields } from './hash' -import { setListInitialState, fetchListElements } from './list' -import { fetchStreamEntries, setStreamInitialState } from './stream' +import { setHashInitialState, fetchHashFields, refreshHashFieldsAction } from './hash' +import { setListInitialState, fetchListElements, refreshListElementsAction } from './list' +import { fetchStreamEntries, refreshStream, setStreamInitialState } from './stream' import { deleteRedisearchHistoryAction, deleteRedisearchKeyFromList, @@ -1249,3 +1250,42 @@ export function editKeyTTLFromList(data: [RedisResponseBuffer, number]) { : dispatch(editRedisearchKeyTTLFromList(data)) } } + +export function refreshKey(key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args?: IFetchKeyArgs) { + return async (dispatch: AppDispatch) => { + const resetData = false + dispatch(refreshKeyInfoAction(key)) + switch (type) { + case KeyTypes.Hash: { + dispatch(refreshHashFieldsAction(key, resetData)) + break + } + case KeyTypes.ZSet: { + dispatch(refreshZsetMembersAction(key, resetData)) + break + } + case KeyTypes.Set: { + dispatch(refreshSetMembersAction(key, resetData)) + break + } + case KeyTypes.List: { + dispatch(refreshListElementsAction(key, resetData)) + break + } + case KeyTypes.String: { + dispatch(fetchString(key, { resetData, end: args?.end || STRING_MAX_LENGTH })) + break + } + case KeyTypes.ReJSON: { + dispatch(fetchReJSON(key, '.', true)) + break + } + case KeyTypes.Stream: { + dispatch(refreshStream(key, resetData)) + break + } + default: + dispatch(fetchKeyInfo(key, resetData)) + } + } +} diff --git a/redisinsight/ui/src/utils/formatters/valueFormatters.tsx b/redisinsight/ui/src/utils/formatters/valueFormatters.tsx index 3dc88465c6..689a8d239c 100644 --- a/redisinsight/ui/src/utils/formatters/valueFormatters.tsx +++ b/redisinsight/ui/src/utils/formatters/valueFormatters.tsx @@ -20,6 +20,7 @@ import { hexToBuffer, stringToBuffer, binaryToBuffer, + Maybe, } from 'uiSrc/utils' import { reSerializeJSON } from 'uiSrc/utils/formatters/json' @@ -40,7 +41,7 @@ const isFormatEditable = (format: KeyValueFormat) => ![ KeyValueFormat.Pickle, ].includes(format) -const isFullStringLoaded = (currentLength: number, fullLength: number) => currentLength === fullLength +const isFullStringLoaded = (currentLength: Maybe, fullLength: Maybe) => currentLength === fullLength const isNonUnicodeFormatter = (format: KeyValueFormat, isValid: boolean) => { if (format === KeyValueFormat.Msgpack) { From 8e2c2c43a583a1d107559b062780975d535b9da5 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 14 Nov 2023 09:52:08 +0100 Subject: [PATCH 41/96] #RI-5033 - Refactor details of key --- .../KeyDetailsHeader.spec.tsx | 28 +- .../KeyDetailsHeaderActions.spec.tsx | 20 ++ .../KeyDetailsHeaderActions.tsx | 160 ++++----- .../KeyDetailsHeaderDelete.tsx | 4 +- .../add-items-panel/AddItemsPanel.spec.tsx | 23 ++ .../add-items-panel/AddItemsPanel.tsx | 8 +- .../DynamicTypeDetails.spec.tsx | 22 ++ .../DynamicTypeDetails.tsx | 3 +- .../components/hash-details/HashDetails.tsx | 1 + .../add-hash-fields/AddHashFields.tsx | 1 + .../add-list-elements/AddListElements.tsx | 1 + .../add-set-members/AddSetMembers.tsx | 1 + .../add-stream-entity/AddStreamEntries.tsx | 1 + .../add-stream-group/AddStreamGroup.tsx | 1 + .../add-zset-members/AddZsetMembers.tsx | 1 + .../components/list-details/ListDetails.tsx | 1 + .../no-key-selected/NoKeySelected.tsx | 2 +- .../rejson-details/RejsonDetailsWrapper.tsx | 5 +- .../components/set-details/SetDetails.tsx | 1 + .../stream-details/StreamDetailsWrapper.tsx | 5 +- .../string-details/StringDetails.tsx | 2 +- .../components/zset-details/ZSetDetails.tsx | 1 + .../ui/src/slices/tests/browser/keys.spec.ts | 309 ++++++++++-------- 23 files changed, 362 insertions(+), 239 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx index d80029786d..0121e28972 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx @@ -1,8 +1,10 @@ import React from 'react' import { mock } from 'ts-mockito' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { cloneDeep } from 'lodash' +import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' import { stringDataSelector } from 'uiSrc/slices/browser/string' import { KeyTypes } from 'uiSrc/constants' +import { deleteSelectedKey } from 'uiSrc/slices/browser/keys' import { Props, KeyDetailsHeader } from './KeyDetailsHeader' const mockedProps = mock() @@ -11,6 +13,15 @@ const KEY_INPUT_TEST_ID = 'edit-key-input' const KEY_BTN_TEST_ID = 'edit-key-btn' const TTL_INPUT_TEST_ID = 'edit-ttl-input' const EDIT_VALUE_BTN_TEST_ID = 'edit-key-value-btn' +const DELETE_KEY_BTN_TEST_ID = 'delete-key-btn' +const DELETE_KEY_CONFIRM_BTN_TEST_ID = 'delete-key-confirm-btn' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) jest.mock('uiSrc/slices/browser/string', () => ({ ...jest.requireActual('uiSrc/slices/browser/string'), @@ -117,10 +128,23 @@ describe('KeyDetailsHeader', () => { }) describe('should call onRefresh', () => { - test.each(Object.values(KeyTypes))('should call onRefresh', (keyType) => { + test.each(Object.values(KeyTypes))('should call onRefresh for keyType: %s', (keyType) => { const component = render() fireEvent.click(screen.getByTestId('refresh-key-btn')) expect(component).toBeTruthy() }) }) + + describe('should call onDelete', () => { + test.each(Object.values(KeyTypes))('should call onDelete for keyType: %s', (keyType) => { + const onRemoveKeyMock = jest.fn() + const component = render() + fireEvent.click(screen.getByTestId(DELETE_KEY_BTN_TEST_ID)) + fireEvent.click(screen.getByTestId(DELETE_KEY_CONFIRM_BTN_TEST_ID)) + expect(component).toBeTruthy() + + const expectedActions = [deleteSelectedKey()] + expect(store.getActions()).toEqual(expect.arrayContaining(expectedActions)) + }) + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx index c3b9ce968b..687d4068d3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx @@ -1,12 +1,32 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' +import { KeyTypes } from 'uiSrc/constants' import { Props, KeyDetailsHeaderActions } from './KeyDetailsHeaderActions' const mockedProps = mock() +const actionsExistsTests: any[] = [ + [KeyTypes.Hash, ['add-key-value-items-btn']], + [KeyTypes.List, ['add-key-value-items-btn', 'remove-key-value-items-btn']], + [KeyTypes.Set, ['add-key-value-items-btn']], + [KeyTypes.ZSet, ['add-key-value-items-btn']], + [KeyTypes.String, ['edit-key-value-btn']], +] + describe('KeyDetailsHeaderActions', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + test.each(actionsExistsTests)('for keyType: %s, actions test id should exist: %s', (keyType: KeyTypes, testIds: string[]) => { + const { queryByTestId } = render() + + testIds.forEach((testId) => { + expect(queryByTestId(testId)).toBeInTheDocument() + }) + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx index 45818dc3ea..333355f9f0 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx @@ -57,96 +57,96 @@ const KeyDetailsHeaderActions = ({ return ( <> {KEY_TYPES_ACTIONS[keyType] && 'addItems' in KEY_TYPES_ACTIONS[keyType] && ( - MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} - position="left" - anchorClassName={cx(styles.actionBtn, { [styles.iiu]: width > MIDDLE_SCREEN_RESOLUTION })} - > - <> - {width > MIDDLE_SCREEN_RESOLUTION ? ( - - {KEY_TYPES_ACTIONS[keyType].addItems?.name} - - ) : ( - - )} - - + MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.iiu]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {KEY_TYPES_ACTIONS[keyType].addItems?.name} + + ) : ( + + )} + + )} {keyType === KeyTypes.Stream && ( - MIDDLE_SCREEN_RESOLUTION ? '' : STREAM_ADD_ACTION[streamViewType].name} - position="left" - anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} - > - <> - {width > MIDDLE_SCREEN_RESOLUTION ? ( - - {STREAM_ADD_ACTION[streamViewType].name} - - ) : ( - - )} - - + MIDDLE_SCREEN_RESOLUTION ? '' : STREAM_ADD_ACTION[streamViewType].name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {STREAM_ADD_ACTION[streamViewType].name} + + ) : ( + + )} + + )} {KEY_TYPES_ACTIONS[keyType] && 'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( - - - - )} - {KEY_TYPES_ACTIONS[keyType] && 'editItem' in KEY_TYPES_ACTIONS[keyType] && ( -
-
+ )} + {KEY_TYPES_ACTIONS[keyType] && 'editItem' in KEY_TYPES_ACTIONS[keyType] && ( +
+ + + +
)} ) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx index d75ae8c159..0494e0a6ed 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx @@ -21,9 +21,7 @@ export interface Props { onDelete: (key: RedisResponseBuffer, type: string) => void } -const KeyDetailsHeaderDelete = ({ - onDelete, -}: Props) => { +const KeyDetailsHeaderDelete = ({ onDelete }: Props) => { const { type, nameString: keyProp, diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx index e0ea9c3e0c..3a283c5894 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx @@ -1,12 +1,35 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' +import { KeyTypes } from 'uiSrc/constants' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { Props, AddItemsPanel } from './AddItemsPanel' const mockedProps = mock() +const AddItemsPanelTypeTests: any[] = [ + [KeyTypes.Hash, 'add-hash-field-panel'], + [KeyTypes.ZSet, 'add-zset-field-panel'], + [KeyTypes.Set, 'add-set-field-panel'], + [KeyTypes.List, 'add-list-field-panel'], + [KeyTypes.Stream, 'add-stream-field-panel', StreamViewType.Data], + [KeyTypes.Stream, 'add-stream-groups-field-panel', StreamViewType.Groups], + [KeyTypes.Stream, 'add-stream-groups-field-panel', StreamViewType.Consumers], + [KeyTypes.Stream, 'add-stream-groups-field-panel', StreamViewType.Messages], +] + describe('AddItemsPanel', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it.each(AddItemsPanelTypeTests)('for key type: %s (reply), data-subj should exists: %s', + (type: KeyTypes, subj: string, strViewType: StreamViewType = StreamViewType.Data) => { + const { container } = render() + expect(container.querySelector(`[data-test-subj=${subj}]`)).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx index 79a83d98de..fde1a5d664 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx @@ -29,16 +29,16 @@ const AddItemsPanel = (props: Props) => { return (
{selectedKeyType === KeyTypes.Hash && ( - + )} {selectedKeyType === KeyTypes.ZSet && ( - + )} {selectedKeyType === KeyTypes.Set && ( - + )} {selectedKeyType === KeyTypes.List && ( - + )} {selectedKeyType === KeyTypes.Stream && ( <> diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx index abc031a050..04f95d2943 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx @@ -1,12 +1,34 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' import { Props, DynamicTypeDetails } from './DynamicTypeDetails' const mockedProps = mock() +const DynamicTypeDetailsTypeTests: any[] = [ + [KeyTypes.Hash, 'hash-details'], + [KeyTypes.ZSet, 'zset-details'], + [KeyTypes.Set, 'set-details'], + [KeyTypes.List, 'list-details'], + [KeyTypes.Stream, 'stream-details'], + [KeyTypes.ReJSON, 'json-details'], + [ModulesKeyTypes.Graph, 'modules-type-details'], + [ModulesKeyTypes.TimeSeries, 'modules-type-details'], + ['123', 'unsupported-type-details'], +] + describe('DynamicTypeDetails', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it.each(DynamicTypeDetailsTypeTests)('for key type: %s (reply), data-subj should exists: %s', + (type: KeyTypes, testId: string) => { + const { queryByTestId } = render() + expect(queryByTestId(testId)).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx index 74ca924d86..db47072d31 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx @@ -66,7 +66,8 @@ const DynamicTypeDetails = (props: Props) => { } // Unsupported key type - if (Object.values(KeyTypes).includes(selectedKeyType as KeyTypes)) { + if (!(Object.values(KeyTypes).includes(selectedKeyType as KeyTypes)) + && !(Object.values(ModulesKeyTypes).includes(selectedKeyType as ModulesKeyTypes))) { return } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx index fe5273d815..860a6f60e8 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx @@ -477,6 +477,7 @@ const HashDetails = (props: Props) => { return ( <>
{ color="transparent" hasShadow={false} borderRadius="none" + data-test-subj="add-hash-field-panel" className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > {fields.map((item, index) => ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx index 9022fe9523..1eea050ad3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx @@ -97,6 +97,7 @@ const AddListElements = (props: Props) => { color="transparent" hasShadow={false} borderRadius="none" + data-test-subj="add-list-field-panel" className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx index b2372d4fb4..0c0f65b551 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx @@ -124,6 +124,7 @@ const AddSetMembers = (props: Props) => { color="transparent" hasShadow={false} borderRadius="none" + data-test-subj="add-set-field-panel" className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth')} > {members.map((item, index) => ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx index 2241adeaea..5b0690a639 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx @@ -109,6 +109,7 @@ const AddStreamEntries = (props: Props) => { color="transparent" hasShadow={false} borderRadius="none" + data-test-subj="add-stream-field-panel" className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > { color="transparent" hasShadow={false} borderRadius="none" + data-test-subj="add-stream-groups-field-panel" className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > { color="transparent" hasShadow={false} borderRadius="none" + data-test-subj="add-zset-field-panel" className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > {members.map((item, index) => ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx index 072e091f36..c049d825a3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx @@ -413,6 +413,7 @@ const ListDetails = (props: Props) => { return (
totalKeys: number - onClosePanel: () => void keysLastRefreshTime: Nullable + onClosePanel: () => void error?: string } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx index 6ca3a2d8ec..833d02f369 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx @@ -67,7 +67,10 @@ const RejsonDetailsWrapper = () => { const reportJSONPropertyAdded = () => {} return ( -
+
{loading && ( { return (
{ ) return ( -
+
{(loading || loadingGroups) && ( { return ( <> -
+
{isLoading && ( { return ( <>
({ ...jest.requireActual('uiSrc/services'), @@ -1066,6 +1067,24 @@ describe('keys slice', () => { }) }) + describe('refreshKey', () => { + it('defaultSelectedKeyAction should be called by default', async () => { + const key = stringToBuffer('key') + + // Act + await store.dispatch( + refreshKey(key, ModulesKeyTypes.Graph) + ) + + // Assert + const expectedActions = [ + refreshKeyInfo(), + defaultSelectedKeyAction() + ] + expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) + }) + }) + describe('thunks', () => { describe('fetchKeys', () => { it('call both loadKeys and loadKeysSuccess when fetch is successed', async () => { @@ -1722,179 +1741,179 @@ describe('keys slice', () => { ] expect(store.getActions()).toEqual(expectedActions) }) + }) - describe('fetchPatternHistoryAction', () => { - it('success fetch history', async () => { - // Arrange - const data: SearchHistoryItem[] = [ - { id: '1', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, - { id: '2', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, - ] - const responsePayload = { data, status: 200 } + describe('fetchPatternHistoryAction', () => { + it('success fetch history', async () => { + // Arrange + const data: SearchHistoryItem[] = [ + { id: '1', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, + { id: '2', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, + ] + const responsePayload = { data, status: 200 } - apiService.get = jest.fn().mockResolvedValue(responsePayload) + apiService.get = jest.fn().mockResolvedValue(responsePayload) - // Act - await store.dispatch(fetchPatternHistoryAction()) + // Act + await store.dispatch(fetchPatternHistoryAction()) - // Assert - const expectedActions = [ - loadSearchHistory(), - loadSearchHistorySuccess(data), - ] - expect(store.getActions()).toEqual(expectedActions) - }) - it('failed to load history', async () => { - // Arrange - const errorMessage = 'some error' - const responsePayload = { - response: { - status: 500, - data: { message: errorMessage }, - }, - } + // Assert + const expectedActions = [ + loadSearchHistory(), + loadSearchHistorySuccess(data), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + it('failed to load history', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } - apiService.get = jest.fn().mockRejectedValue(responsePayload) + apiService.get = jest.fn().mockRejectedValue(responsePayload) - // Act - await store.dispatch(fetchPatternHistoryAction()) + // Act + await store.dispatch(fetchPatternHistoryAction()) - // Assert - const expectedActions = [ - loadSearchHistory(), - loadSearchHistoryFailure(), - ] - expect(store.getActions()).toEqual(expectedActions) - }) + // Assert + const expectedActions = [ + loadSearchHistory(), + loadSearchHistoryFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) }) + }) - describe('fetchSearchHistoryAction', () => { - it('success fetch history', async () => { - // Arrange - const data: SearchHistoryItem[] = [ - { id: '1', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, - { id: '2', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, - ] - const responsePayload = { data, status: 200 } + describe('fetchSearchHistoryAction', () => { + it('success fetch history', async () => { + // Arrange + const data: SearchHistoryItem[] = [ + { id: '1', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, + { id: '2', mode: SearchMode.Pattern, filter: { type: 'list', match: '*' } }, + ] + const responsePayload = { data, status: 200 } - apiService.get = jest.fn().mockResolvedValue(responsePayload) + apiService.get = jest.fn().mockResolvedValue(responsePayload) - // Act - await store.dispatch(fetchSearchHistoryAction(SearchMode.Pattern)) + // Act + await store.dispatch(fetchSearchHistoryAction(SearchMode.Pattern)) - // Assert - const expectedActions = [ - loadSearchHistory(), - loadSearchHistorySuccess(data), - ] - expect(store.getActions()).toEqual(expectedActions) - }) - it('failed to load history', async () => { - // Arrange - const errorMessage = 'some error' - const responsePayload = { - response: { - status: 500, - data: { message: errorMessage }, - }, - } + // Assert + const expectedActions = [ + loadSearchHistory(), + loadSearchHistorySuccess(data), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + it('failed to load history', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } - apiService.get = jest.fn().mockRejectedValue(responsePayload) + apiService.get = jest.fn().mockRejectedValue(responsePayload) - // Act - await store.dispatch(fetchSearchHistoryAction(SearchMode.Pattern)) + // Act + await store.dispatch(fetchSearchHistoryAction(SearchMode.Pattern)) - // Assert - const expectedActions = [ - loadSearchHistory(), - loadSearchHistoryFailure(), - ] - expect(store.getActions()).toEqual(expectedActions) - }) + // Assert + const expectedActions = [ + loadSearchHistory(), + loadSearchHistoryFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) }) + }) - describe('deletePatternHistoryAction', () => { - it('success delete history', async () => { - // Arrange - const responsePayload = { status: 200 } + describe('deletePatternHistoryAction', () => { + it('success delete history', async () => { + // Arrange + const responsePayload = { status: 200 } - apiService.delete = jest.fn().mockResolvedValue(responsePayload) + apiService.delete = jest.fn().mockResolvedValue(responsePayload) - // Act - await store.dispatch(deletePatternHistoryAction(['1'])) + // Act + await store.dispatch(deletePatternHistoryAction(['1'])) - // Assert - const expectedActions = [ - deleteSearchHistory(), - deleteSearchHistorySuccess(['1']), - ] - expect(store.getActions()).toEqual(expectedActions) - }) - - it('failed to delete history', async () => { - // Arrange - const errorMessage = 'some error' - const responsePayload = { - response: { - status: 500, - data: { message: errorMessage }, - }, - } + // Assert + const expectedActions = [ + deleteSearchHistory(), + deleteSearchHistorySuccess(['1']), + ] + expect(store.getActions()).toEqual(expectedActions) + }) - apiService.delete = jest.fn().mockRejectedValue(responsePayload) + it('failed to delete history', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } - // Act - await store.dispatch(deletePatternHistoryAction(['1'])) + apiService.delete = jest.fn().mockRejectedValue(responsePayload) - // Assert - const expectedActions = [ - deleteSearchHistory(), - deleteSearchHistoryFailure(), - ] - expect(store.getActions()).toEqual(expectedActions) - }) + // Act + await store.dispatch(deletePatternHistoryAction(['1'])) + + // Assert + const expectedActions = [ + deleteSearchHistory(), + deleteSearchHistoryFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) }) + }) - describe('deleteSearchHistoryAction', () => { - it('success delete history', async () => { - // Arrange - const responsePayload = { status: 200 } + describe('deleteSearchHistoryAction', () => { + it('success delete history', async () => { + // Arrange + const responsePayload = { status: 200 } - apiService.delete = jest.fn().mockResolvedValue(responsePayload) + apiService.delete = jest.fn().mockResolvedValue(responsePayload) - // Act - await store.dispatch(deleteSearchHistoryAction(SearchMode.Pattern, ['1'])) + // Act + await store.dispatch(deleteSearchHistoryAction(SearchMode.Pattern, ['1'])) - // Assert - const expectedActions = [ - deleteSearchHistory(), - deleteSearchHistorySuccess(['1']), - ] - expect(store.getActions()).toEqual(expectedActions) - }) - - it('failed to delete history', async () => { - // Arrange - const errorMessage = 'some error' - const responsePayload = { - response: { - status: 500, - data: { message: errorMessage }, - }, - } + // Assert + const expectedActions = [ + deleteSearchHistory(), + deleteSearchHistorySuccess(['1']), + ] + expect(store.getActions()).toEqual(expectedActions) + }) - apiService.delete = jest.fn().mockRejectedValue(responsePayload) + it('failed to delete history', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } - // Act - await store.dispatch(deleteSearchHistoryAction(SearchMode.Pattern, ['1'])) + apiService.delete = jest.fn().mockRejectedValue(responsePayload) - // Assert - const expectedActions = [ - deleteSearchHistory(), - deleteSearchHistoryFailure(), - ] - expect(store.getActions()).toEqual(expectedActions) - }) + // Act + await store.dispatch(deleteSearchHistoryAction(SearchMode.Pattern, ['1'])) + + // Assert + const expectedActions = [ + deleteSearchHistory(), + deleteSearchHistoryFailure(), + ] + expect(store.getActions()).toEqual(expectedActions) }) }) }) From ea881aefe1ac09b5ff4861c3895da73817e462ed Mon Sep 17 00:00:00 2001 From: Amir Allayarov <100589048+AmirAllayarovSofteq@users.noreply.github.com> Date: Tue, 14 Nov 2023 13:48:27 +0400 Subject: [PATCH 42/96] #RI-5144 - fix form fill values (#2776) --- .../manual-connection/ManualConnectionWrapper.spec.tsx | 2 -- redisinsight/ui/src/pages/home/utils/form.tsx | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx index 0ee1ee6e8a..5ce57fa673 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.spec.tsx @@ -3,13 +3,11 @@ import { instance, mock } from 'ts-mockito' import { act } from '@testing-library/react' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { - DEFAULT_TIMEOUT, SubmitBtnText, } from 'uiSrc/pages/home/constants' import ManualConnectionFrom, { Props as ManualConnectionFromProps } from 'uiSrc/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import SentinelConnectionWrapper from 'uiSrc/pages/home/components/sentinel-connection' import ManualConnectionWrapper, { Props, } from './ManualConnectionWrapper' diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx index c11312af5f..ddb493db44 100644 --- a/redisinsight/ui/src/pages/home/utils/form.tsx +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -258,7 +258,7 @@ export const autoFillFormDetails = ( } } } - setInitialValues(getUpdatedInitialValues()) + setInitialValues(getFormValues(getUpdatedInitialValues())) /* * autofill was successfull so return true */ @@ -301,7 +301,7 @@ export const getFormValues = (instance?: Nullable>) => ({ compressor: instance?.compressor ?? NONE, modules: instance?.modules, showDb: !!instance?.db, - showCompressor: instance && instance.compressor !== NONE, + showCompressor: instance && instance.compressor && instance.compressor !== NONE, sni: !!instance?.servername, servername: instance?.servername, newCaCert: '', From e928062c92e33241a434a4ac0d936a0dff8b4a59 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 14 Nov 2023 12:00:58 +0100 Subject: [PATCH 43/96] #RI-5033 - fix Props import --- .../components/add-items-actions/AddItemsActions.spec.tsx | 3 +-- .../browser/components/add-items-actions/AddItemsActions.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.spec.tsx index 3b7e65d7f9..e9ec3607b2 100644 --- a/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.spec.tsx @@ -1,8 +1,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import AddItemsActions from './AddItemsActions' -import { Props } from '../../modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers' +import AddItemsActions, { Props } from './AddItemsActions' const mockedProps = mock() diff --git a/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.tsx b/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.tsx index 2c195c339a..7116935e11 100644 --- a/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.tsx @@ -1,7 +1,7 @@ import React from 'react' import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui' -interface Props { +export interface Props { id: number length: number index: number From 992cd24e88b67b4ffcb153b4c6e1622b14ee189b Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 14 Nov 2023 14:08:09 +0100 Subject: [PATCH 44/96] #RI-5033 - Refactor key details version 2 --- .../ui/src/pages/browser/modules/index.ts | 1 + .../KeyDetailsHeader.spec.tsx | 4 +- .../key-details-header/KeyDetailsHeader.tsx | 10 +- .../modules/key-details-header/index.ts | 3 +- .../modules/key-details/KeyDetails.tsx | 126 ++-- .../add-items-panel/AddItemsPanel.tsx | 4 +- .../DynamicTypeDetails.spec.tsx | 2 +- .../DynamicTypeDetails.tsx | 66 +-- .../hash-details/HashDetails.spec.tsx | 129 +---- .../components/hash-details/HashDetails.tsx | 542 ++---------------- .../HashDetailsTable.spec.tsx | 137 +++++ .../hash-details-table/HashDetailsTable.tsx | 527 +++++++++++++++++ .../hash-details/hash-details-table/index.ts | 1 + .../styles.module.scss | 0 .../components/hash-details/index.ts | 1 + .../AddListElements.spec.tsx | 2 +- .../list-details/ListDetails.spec.tsx | 128 +---- .../components/list-details/ListDetails.tsx | 497 ++-------------- .../components/list-details/index.ts | 1 + .../ListDetailsTable.spec.tsx | 132 +++++ .../list-details-table/ListDetailsTable.tsx | 463 +++++++++++++++ .../list-details/list-details-table/index.ts | 1 + .../list-details-table/styles.module.scss | 41 ++ .../list-details/styles.module.scss | 45 +- .../RejsonDetailsWrapper.spec.tsx | 7 +- .../rejson-details/RejsonDetailsWrapper.tsx | 88 +-- .../components/rejson-details/index.ts | 1 + .../set-details/SetDetails.spec.tsx | 73 +-- .../components/set-details/SetDetails.tsx | 334 ++--------- .../components/set-details/index.ts | 1 + .../SetDetailsTable.spec.tsx | 81 +++ .../set-details-table/SetDetailsTable.tsx | 314 ++++++++++ .../set-details/set-details-table/index.ts | 1 + .../styles.module.scss | 0 .../stream-details/StreamDetails.spec.tsx | 12 + .../stream-details/StreamDetails.tsx | 71 +++ .../components/stream-details/index.ts | 4 +- .../StreamDetailsBody.spec.tsx} | 16 +- .../StreamDetailsBody.tsx} | 16 +- .../stream-details-body/index.ts | 1 + .../styles.module.scss | 0 .../string-details/StringDetails.spec.tsx | 283 +-------- .../string-details/StringDetails.tsx | 313 +--------- .../components/string-details/index.ts | 1 + .../StringDetailsTable.spec.tsx | 289 ++++++++++ .../StringDetailsTable.tsx | 303 ++++++++++ .../string-details-table/index.ts | 1 + .../styles.module.scss | 0 .../zset-details/ZSetDetails.spec.tsx | 92 +-- .../components/zset-details/ZSetDetails.tsx | 487 ++-------------- .../components/zset-details/index.ts | 1 + .../ZSetDetailsTable.spec.tsx | 100 ++++ .../zset-details-table/ZSetDetailsTable.tsx | 471 +++++++++++++++ .../zset-details/zset-details-table/index.ts | 1 + .../styles.module.scss | 0 55 files changed, 3323 insertions(+), 2902 deletions(-) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/{ => hash-details-table}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/{ => set-details-table}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx rename redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/{StreamDetailsWrapper.spec.tsx => stream-details-body/StreamDetailsBody.spec.tsx} (86%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/{StreamDetailsWrapper.tsx => stream-details-body/StreamDetailsBody.tsx} (94%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/{ => stream-details-body}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/{ => string-details-table}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/{ => zset-details-table}/styles.module.scss (100%) diff --git a/redisinsight/ui/src/pages/browser/modules/index.ts b/redisinsight/ui/src/pages/browser/modules/index.ts index d9f9bbcc8f..eaff655937 100644 --- a/redisinsight/ui/src/pages/browser/modules/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/index.ts @@ -1,2 +1,3 @@ export { KeyDetails } from './key-details' export { KeyDetailsHeader } from './key-details-header' +export type { KeyDetailsHeaderProps } from './key-details-header' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx index 0121e28972..9cce3d919f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx @@ -5,9 +5,9 @@ import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/tes import { stringDataSelector } from 'uiSrc/slices/browser/string' import { KeyTypes } from 'uiSrc/constants' import { deleteSelectedKey } from 'uiSrc/slices/browser/keys' -import { Props, KeyDetailsHeader } from './KeyDetailsHeader' +import { KeyDetailsHeaderProps, KeyDetailsHeader } from './KeyDetailsHeader' -const mockedProps = mock() +const mockedProps = mock() const KEY_INPUT_TEST_ID = 'edit-key-input' const KEY_BTN_TEST_ID = 'edit-key-btn' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 555fa588a9..534efbd346 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -39,9 +39,9 @@ import { HIDE_LAST_REFRESH } from './constants' import styles from './styles.module.scss' -export interface Props { +export interface KeyDetailsHeaderProps { keyType: KeyTypes | ModulesKeyTypes - onClose: (key: RedisResponseBuffer) => void + onCloseKey: (key: RedisResponseBuffer) => void onRemoveKey: () => void onEditKey: (key: RedisResponseBuffer, newKey: RedisResponseBuffer, onFailure?: () => void) => void onAddItem?: () => void @@ -56,14 +56,14 @@ const KeyDetailsHeader = ({ isFullScreen, arePanelsCollapsed, onToggleFullScreen = () => {}, - onClose, + onCloseKey, onRemoveKey, onEditKey, keyType, onAddItem = () => {}, onEditItem = () => {}, onRemoveItem = () => {}, -}: Props) => { +}: KeyDetailsHeaderProps) => { const { loading, lastRefreshTime } = useSelector(selectedKeySelector) const { type, @@ -158,7 +158,7 @@ const KeyDetailsHeader = ({ color="primary" aria-label="Close key" className={styles.closeBtn} - onClick={() => onClose(keyProp)} + onClick={() => onCloseKey(keyProp)} data-testid="close-key-btn" /> diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts index 1e52d0f3ec..f5a0b7c62d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/index.ts @@ -1 +1,2 @@ -export { KeyDetailsHeader } from './KeyDetailsHeader' \ No newline at end of file +export { KeyDetailsHeader } from './KeyDetailsHeader' +export type { KeyDetailsHeaderProps } from './KeyDetailsHeader' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx index 1cc01adb87..39c82423bd 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/KeyDetails.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' import { isNull, isUndefined } from 'lodash' import { useDispatch, useSelector } from 'react-redux' import { useParams } from 'react-router-dom' @@ -10,17 +10,13 @@ import { selectedKeyDataSelector, selectedKeySelector, } from 'uiSrc/slices/browser/keys' -import { KeyTypes, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' +import { KeyTypes } from 'uiSrc/constants' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { Nullable } from 'uiSrc/utils' -import { KeyDetailsHeader } from 'uiSrc/pages/browser/modules' -import { streamSelector } from 'uiSrc/slices/browser/stream' import { NoKeySelected } from './components/no-key-selected' import { DynamicTypeDetails } from './components/dynamic-type-details' -import { AddItemsPanel } from './components/add-items-panel' -import { RemoveListElements } from './components/key-details-remove-items' import styles from './styles.module.scss' @@ -39,8 +35,6 @@ export interface Props { const KeyDetails = (props: Props) => { const { onCloseKey, - onEditKey, - onRemoveKey, keyProp, totalKeys, keysLastRefreshTime, @@ -50,15 +44,10 @@ const KeyDetails = (props: Props) => { const { viewType } = useSelector(keysSelector) const { loading, error = '', data } = useSelector(selectedKeySelector) const isKeySelected = !isNull(useSelector(selectedKeyDataSelector)) - const { viewType: streamViewType } = useSelector(streamSelector) const { type: keyType, name: keyName, length: keyLength } = useSelector(selectedKeyDataSelector) ?? { type: KeyTypes.String, } - const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) - const [isRemoveItemPanelOpen, setIsRemoveItemPanelOpen] = useState(false) - const [editItem, setEditItem] = useState(false) - const dispatch = useDispatch() useEffect(() => { @@ -87,48 +76,32 @@ const KeyDetails = (props: Props) => { } }, [keyName]) - const openAddItemPanel = () => { - setIsRemoveItemPanelOpen(false) - setIsAddItemPanelOpen(true) - if (!STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_ADD_VALUE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CLICKED - ), - eventData: { - databaseId: instanceId, - keyType - } - }) - } + const onCloseAddItemPanel = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_ADD_VALUE_CANCELLED, + TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CANCELLED, + ), + eventData: { + databaseId: instanceId, + keyType, + } + }) } - const openRemoveItemPanel = () => { - setIsAddItemPanelOpen(false) - setIsRemoveItemPanelOpen(true) - } - - const closeAddItemPanel = (isCancelled?: boolean) => { - if (isCancelled && isAddItemPanelOpen && !STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_ADD_VALUE_CANCELLED, - TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CANCELLED, - ), - eventData: { - databaseId: instanceId, - keyType, - } - }) - } - setIsAddItemPanelOpen(false) - } - - const closeRemoveItemPanel = () => { - setIsRemoveItemPanelOpen(false) + const onOpenAddItemPanel = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_ADD_VALUE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType, + } + }) } return ( @@ -143,47 +116,12 @@ const KeyDetails = (props: Props) => { onClosePanel={onCloseKey} /> ) : ( -
- setEditItem(!editItem)} - onRemoveKey={onRemoveKey} - onClose={onCloseKey} - onEditKey={onEditKey} - /> -
- {!loading && ( -
- -
- )} - {isAddItemPanelOpen && ( - - )} - {isRemoveItemPanelOpen && ( -
- {keyType === KeyTypes.List && ( - - )} -
- )} -
-
+ )}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx index fde1a5d664..b6acdda49e 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx @@ -15,7 +15,7 @@ import styles from './styles.module.scss' export interface Props { selectedKeyType: KeyTypes | ModulesKeyTypes - streamViewType: StreamViewType + streamViewType?: StreamViewType closeAddItemPanel: (isCancelled?: boolean) => void } @@ -45,7 +45,7 @@ const AddItemsPanel = (props: Props) => { {streamViewType === StreamViewType.Data && ( )} - {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType) && ( + {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType!) && ( )} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx index 04f95d2943..f97fea3641 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx @@ -27,7 +27,7 @@ describe('DynamicTypeDetails', () => { (type: KeyTypes, testId: string) => { const { queryByTestId } = render() expect(queryByTestId(testId)).toBeInTheDocument() }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx index db47072d31..e801038b8f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx @@ -1,58 +1,32 @@ import React from 'react' -import { useDispatch } from 'react-redux' import { KeyTypes, MODULES_KEY_TYPES_NAMES, ModulesKeyTypes } from 'uiSrc/constants' -import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' -import { refreshKey } from 'uiSrc/slices/browser/keys' -import ZSetDetails from '../zset-details/ZSetDetails' -import SetDetails from '../set-details/SetDetails' -import StringDetails from '../string-details/StringDetails' -import HashDetails from '../hash-details/HashDetails' -import ListDetails from '../list-details/ListDetails' -import RejsonDetailsWrapper from '../rejson-details/RejsonDetailsWrapper' -import StreamDetailsWrapper from '../stream-details' +import { KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import ModulesTypeDetails from '../modules-type-details/ModulesTypeDetails' import UnsupportedTypeDetails from '../unsupported-type-details/UnsupportedTypeDetails' - -export interface Props { - selectedKeyType: KeyTypes | ModulesKeyTypes - isAddItemPanelOpen: boolean - isRemoveItemPanelOpen: boolean - editItem: boolean - onRemoveKey: ()=> void - setEditItem: (isEdit: boolean) => void +import { RejsonDetailsWrapper } from '../rejson-details' +import { StringDetails } from '../string-details' +import { ZSetDetails } from '../zset-details' +import { SetDetails } from '../set-details' +import { HashDetails } from '../hash-details' +import { ListDetails } from '../list-details' +import { StreamDetails } from '../stream-details' + +export interface Props extends KeyDetailsHeaderProps { + onOpenAddItemPanel: () => void + onCloseAddItemPanel: () => void } const DynamicTypeDetails = (props: Props) => { - const { - selectedKeyType, - isAddItemPanelOpen, - onRemoveKey, - isRemoveItemPanelOpen, - editItem, - setEditItem, - } = props - - const dispatch = useDispatch() - - const handleRefreshKey = (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args: IFetchKeyArgs) => { - dispatch(refreshKey(key, type, args)) - } + const { keyType: selectedKeyType } = props const TypeDetails: any = { - [KeyTypes.ZSet]: , - [KeyTypes.Set]: , - [KeyTypes.String]: ( - setEditItem(isEdit)} - onRefresh={handleRefreshKey} - /> - ), - [KeyTypes.Hash]: , - [KeyTypes.List]: , - [KeyTypes.ReJSON]: , - [KeyTypes.Stream]: , + [KeyTypes.ZSet]: , + [KeyTypes.Set]: , + [KeyTypes.String]: , + [KeyTypes.Hash]: , + [KeyTypes.List]: , + [KeyTypes.ReJSON]: , + [KeyTypes.Stream]: , } // Supported key type diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.spec.tsx index 51c452a8f1..da72dfa82c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.spec.tsx @@ -1,137 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { KeyValueCompressor, TEXT_DISABLED_COMPRESSED_VALUE } from 'uiSrc/constants' -import { hashDataSelector } from 'uiSrc/slices/browser/hash' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { RedisResponseBufferType } from 'uiSrc/slices/interfaces' -import { anyToBuffer, bufferToString } from 'uiSrc/utils' -import { act, fireEvent, render, screen, waitForEuiToolTipVisible } from 'uiSrc/utils/test-utils' -import { GZIP_COMPRESSED_VALUE_1, GZIP_COMPRESSED_VALUE_2, DECOMPRESSED_VALUE_STR_1, DECOMPRESSED_VALUE_STR_2 } from 'uiSrc/utils/tests/decompressors' -import HashDetails, { Props } from './HashDetails' +import { render } from 'uiSrc/utils/test-utils' +import { Props, HashDetails } from './HashDetails' const mockedProps = mock() -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] } }, -] - -jest.mock('uiSrc/slices/browser/hash', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState - return ({ - hashSelector: jest.fn().mockReturnValue(defaultState), - updateHashValueStateSelector: jest.fn().mockReturnValue(defaultState.updateValue), - hashDataSelector: jest.fn().mockReturnValue({ - ...defaultState, - total: 3, - key: '123zxczxczxc', - fields - }), - setHashInitialState: jest.fn, - fetchHashFields: () => jest.fn - }) -}) - -jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - connectedInstanceSelector: jest.fn().mockReturnValue({ - compressor: null, - }), -})) describe('HashDetails', () => { it('should render', () => { expect(render()).toBeTruthy() }) - - it('should render rows properly', () => { - const { container } = render() - const rows = container.querySelectorAll('.ReactVirtualized__Table__row[role="row"]') - expect(rows).toHaveLength(fields.length) - }) - - it('should render search input', () => { - render() - expect(screen.getByTestId('search')).toBeTruthy() - }) - - it('should call search', () => { - render() - const searchInput = screen.getByTestId('search') - fireEvent.change( - searchInput, - { target: { value: '*1*' } } - ) - expect(searchInput).toHaveValue('*1*') - }) - - it('should render delete popup after click remove button', () => { - render() - fireEvent.click(screen.getAllByTestId(/remove-hash-button/)[0]) - expect(screen.getByTestId(`remove-hash-button-${bufferToString(fields[0].field)}-icon`)).toBeInTheDocument() - }) - - it('should render editor after click edit button', () => { - render() - 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() - }) - - describe('decompressed data', () => { - it('should render decompressed GZIP data', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState - const hashDataSelectorMock = jest.fn().mockReturnValue({ - ...defaultState, - total: 1, - key: '123zxczxczxc', - fields: [ - { field: anyToBuffer(GZIP_COMPRESSED_VALUE_1), value: anyToBuffer(GZIP_COMPRESSED_VALUE_2) }, - ] - }) - hashDataSelector.mockImplementation(hashDataSelectorMock) - - const { queryByTestId, queryAllByTestId } = render() - const fieldEl = queryAllByTestId(/hash-field-/)?.[0] - const valueEl = queryByTestId(/hash-field-value/) - - expect(fieldEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) - expect(valueEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_2) - }) - - it('edit button should be disabled if data was compressed', async () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState - const hashDataSelectorMock = jest.fn().mockReturnValue({ - ...defaultState, - total: 1, - key: '123zxczxczxc', - fields: [ - { field: anyToBuffer(GZIP_COMPRESSED_VALUE_1), value: anyToBuffer(GZIP_COMPRESSED_VALUE_2) }, - ] - }) - hashDataSelector.mockImplementation(hashDataSelectorMock) - - connectedInstanceSelector.mockImplementation(() => ({ - compressor: KeyValueCompressor.GZIP, - })) - - const { queryByTestId } = render() - const editBtn = queryByTestId(/edit-hash-button/) - - fireEvent.click(editBtn) - - await act(async () => { - fireEvent.mouseOver(editBtn) - }) - await waitForEuiToolTipVisible() - - expect(editBtn).toBeDisabled() - expect(screen.getByTestId('hash-edit-tooltip')).toHaveTextContent(TEXT_DISABLED_COMPRESSED_VALUE) - expect(queryByTestId('hash-value-editor')).not.toBeInTheDocument() - }) - }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx index 860a6f60e8..7cd1e944db 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx @@ -1,527 +1,63 @@ -import { EuiButtonIcon, EuiProgress, EuiText, EuiTextArea, EuiToolTip } from '@elastic/eui' -import cx from 'classnames' -import React, { ChangeEvent, Ref, useCallback, useEffect, useRef, useState } from 'react' -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_COMPRESSED_VALUE, - TEXT_DISABLED_FORMATTER_EDITING, - TEXT_FAILED_CONVENT_FORMATTER, - 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 React, { useState } from 'react' +import { useSelector } from 'react-redux' import { - deleteHashFields, - fetchHashFields, - fetchMoreHashFields, - 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, RedisString } from 'uiSrc/slices/interfaces' -import { getBasedOnViewTypeEvent, getMatchType, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { - bufferToSerializedFormat, - bufferToString, - createDeleteFieldHeader, - createDeleteFieldMessage, - formatLongName, - formattingBuffer, - isEqualBuffers, - isFormatEditable, - isNonUnicodeFormatter, - Nullable, - stringToSerializedBufferFormat -} from 'uiSrc/utils' -import { stringToBuffer } from 'uiSrc/utils/formatters/bufferFormatters' -import { decompressingBuffer } from 'uiSrc/utils/decompressors' -import { AddFieldsToHashDto, GetHashFieldsResponse, HashFieldDto, } from 'apiSrc/modules/browser/dto/hash.dto' + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes } from 'uiSrc/constants' -import PopoverDelete from '../../../../components/popover-delete/PopoverDelete' -import styles from './styles.module.scss' +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' +import { HashDetailsTable } from './hash-details-table' -const suffix = '_hash' -const matchAllValue = '*' -const headerHeight = 60 -const rowHeight = 43 +import { AddItemsPanel } from '../add-items-panel' -const cellCache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: rowHeight, -}) - -interface IHashField extends HashFieldDto {} - -export interface Props { - isFooterOpen: boolean +export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void + onOpenAddItemPanel: () => void + onCloseAddItemPanel: () => void } const HashDetails = (props: Props) => { - const { isFooterOpen, onRemoveKey } = props - - const { - total, - nextCursor, - fields: loadedFields, - } = useSelector(hashDataSelector) - const { loading } = useSelector(hashSelector) - const { viewType } = useSelector(keysSelector) - const { id: instanceId, compressor = null } = useSelector(connectedInstanceSelector) - 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('') - const [fields, setFields] = useState([]) - const [editingIndex, setEditingIndex] = useState>(null) - const [width, setWidth] = useState(100) - const [expandedRows, setExpandedRows] = useState([]) - const [viewFormat, setViewFormat] = useState(viewFormatProp) - const [areaValue, setAreaValue] = useState('') - const [, forceUpdate] = useState({}) - - const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) - const textAreaRef: Ref = useRef(null) - - const dispatch = useDispatch() - - useEffect(() => { - setFields(loadedFields) - - if (loadedFields.length < fields.length) { - formattedLastIndexRef.current = 0 - } + const keyType = KeyTypes.Hash + const { onRemoveKey, onOpenAddItemPanel, onCloseAddItemPanel } = props - if (viewFormat !== viewFormatProp) { - setExpandedRows([]) - setViewFormat(viewFormatProp) - setEditingIndex(null) + const { loading } = useSelector(selectedKeySelector) - clearCache() - } - }, [loadedFields, viewFormatProp]) + const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) - const clearCache = () => setTimeout(() => { - cellCache.clearAll() - forceUpdate({}) - }, 0) - - const closePopover = useCallback(() => { - setDeleting('') - }, []) - - const showPopover = useCallback((field = '') => { - setDeleting(`${field + suffix}`) - }, []) - - const onSuccessRemoved = (newTotalValue: number) => { - newTotalValue === 0 && onRemoveKey() - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.Hash, - numberOfRemoved: 1, - } - }) - } - - const handleDeleteField = (field: RedisString | string = '') => { - dispatch(deleteHashFields(key, [field], onSuccessRemoved)) - closePopover() + const openAddItemPanel = () => { + setIsAddItemPanelOpen(true) + onOpenAddItemPanel() } - const handleEditField = useCallback(( - rowIndex: Nullable = null, - editing: boolean, - valueItem?: RedisResponseBuffer - ) => { - setEditingIndex(editing ? rowIndex : null) - - if (editing) { - const value = bufferToSerializedFormat(viewFormat, valueItem, 4) - setAreaValue(value) - - setTimeout(() => { - textAreaRef?.current?.focus() - }, 0) - } - - // hack to update scrollbar padding - clearCache() - setTimeout(() => { - clearCache() - }, 0) - }, [viewFormat]) - - const handleApplyEditField = (field = '') => { - const data: AddFieldsToHashDto = { - keyName: key, - fields: [{ field, value: stringToSerializedBufferFormat(viewFormat, areaValue) }], - } - dispatch(updateHashFieldsAction(data, () => onHashEditedSuccess(field))) + const closeAddItemPanel = () => { + setIsAddItemPanelOpen(false) + onCloseAddItemPanel() } - const onHashEditedSuccess = (fieldName = '') => { - handleEditField(fieldName, false) - } - - const handleRemoveIconClick = () => { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.Hash - } - }) - } - - const handleSearch = (search: IColumnSearchState[]) => { - const fieldColumn = search.find((column) => column.id === 'field') - if (fieldColumn) { - const { value: match } = fieldColumn - const onSuccess = (data: GetHashFieldsResponse) => { - const matchValue = getMatchType(match) - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.Hash, - match: matchValue, - length: data.total, - } - }) - } - setMatch(match) - dispatch(fetchHashFields(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, true, onSuccess)) - } - } - - const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { - const browserViewEvent = expanded - ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED - const treeViewEvent = expanded - ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED - - sendEventTelemetry({ - event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), - eventData: { - keyType: KeyTypes.Hash, - databaseId: instanceId, - largestCellLength: Math.max(...Object.values(fields[rowIndex]).map((a) => a.toString().length)) || 0, - } - }) - - cellCache.clearAll() - } - - const loadMoreItems = () => { - if (nextCursor !== 0) { - dispatch( - fetchMoreHashFields( - key as RedisResponseBuffer, - nextCursor, - SCAN_COUNT_DEFAULT, - match || matchAllValue - ) - ) - } - } - - const updateTextAreaHeight = () => { - if (textAreaRef.current) { - textAreaRef.current.style.height = '0px' - textAreaRef.current.style.height = `${textAreaRef.current?.scrollHeight || 0}px` - } - } - - 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, - alignment: TableCellAlignment.Left, - className: 'value-table-separate-border', - headerClassName: 'value-table-separate-border', - render: (_name: string, { field: fieldItem }: HashFieldDto, expanded?: boolean) => { - const { value: decompressedItem } = decompressingBuffer(fieldItem, compressor) - const field = bufferToString(fieldItem) || '' - // Better to cut the long string, because it could affect virtual scroll performance - const tooltipContent = formatLongName(field) - const { value, isValid } = formattingBuffer(decompressedItem, viewFormatProp, { expanded }) - - return ( - -
- {!expanded && ( - - <>{value?.substring?.(0, 200) ?? value} - - )} - {expanded && value} -
-
- ) - }, - }, - { - id: 'value', - label: 'Value', - minWidth: 120, - truncateText: true, - alignment: TableCellAlignment.Left, - render: function Value( - _name: string, - { field: fieldItem, value: valueItem }: IHashField, - expanded?: boolean, - rowIndex = 0 - ) { - const { value: decompressedFieldItem } = decompressingBuffer(fieldItem, compressor) - const { value: decompressedValueItem } = decompressingBuffer(valueItem, compressor) - const value = bufferToString(valueItem) - const field = bufferToString(decompressedFieldItem) - // Better to cut the long string, because it could affect virtual scroll performance - const tooltipContent = formatLongName(value) - const { value: formattedValue, isValid } = formattingBuffer(decompressedValueItem, viewFormatProp, { expanded }) - - if (rowIndex === editingIndex) { - const disabled = !isNonUnicodeFormatter(viewFormat, isValid) - && !isEqualBuffers(valueItem, stringToBuffer(value)) - - setTimeout(() => cellCache.clear(rowIndex, 1), 0) - updateTextAreaHeight() - - return ( - setTimeout(updateTextAreaHeight, 0)}> - {({ width }) => ( -
- - handleEditField(rowIndex, false)} - onApply={() => handleApplyEditField(fieldItem)} - approveText={TEXT_INVALID_VALUE} - approveByValidation={() => - formattingBuffer( - stringToSerializedBufferFormat(viewFormat, areaValue), - viewFormat - )?.isValid} - > - ) => { - cellCache.clearAll() - setAreaValue(e.target.value) - updateTextAreaHeight() - }} - disabled={updateLoading} - inputRef={textAreaRef} - className={cx(styles.textArea, { [styles.areaWarning]: disabled })} - spellCheck={false} - data-testid="hash-value-editor" - style={{ height: textAreaRef.current?.scrollHeight || 0 }} - /> - - -
- )} -
- ) - } - return ( - -
- {!expanded && ( - - <>{formattedValue?.substring?.(0, 200) ?? formattedValue} - - )} - {expanded && formattedValue} -
-
- ) - }, - }, - { - id: 'actions', - label: '', - headerClassName: 'value-table-header-actions', - className: 'actions', - absoluteWidth: 95, - minWidth: 95, - maxWidth: 95, - render: function Actions(_act: any, { field: fieldItem, value: valueItem }: HashFieldDto, _, rowIndex?: number) { - const field = bufferToString(fieldItem, viewFormat) - const { isCompressed } = decompressingBuffer(valueItem, compressor) - const isEditable = !isCompressed && isFormatEditable(viewFormat) - const tooltipContent = isCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING - return ( - -
- - handleEditField(rowIndex, true, valueItem)} - data-testid={`edit-hash-button-${field}`} - /> - - -
-
- ) - }, - }, - ] - return ( - <> -
+ +
+ {!loading && ( +
+ +
)} - > - {loading && ( - )} - ({ - ...column, - width: getColumnWidth(i, width, arr) - }))} - footerHeight={0} - overscanRowCount={10} - loadMoreItems={loadMoreItems} - loading={loading} - items={fields} - totalItemsCount={total} - noItemsMessage={NoResultsFoundText} - onWheel={closePopover} - onSearch={handleSearch} - cellCache={cellCache} - onRowToggleViewClick={handleRowToggleViewClick} - expandedRows={expandedRows} - setExpandedRows={setExpandedRows} - onColResizeEnd={onColResizeEnd} - />
- +
) } -export default HashDetails +export { HashDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.spec.tsx new file mode 100644 index 0000000000..4a4c841ba3 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.spec.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { KeyValueCompressor, TEXT_DISABLED_COMPRESSED_VALUE } from 'uiSrc/constants' +import { hashDataSelector } from 'uiSrc/slices/browser/hash' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { RedisResponseBufferType } from 'uiSrc/slices/interfaces' +import { anyToBuffer, bufferToString } from 'uiSrc/utils' +import { act, fireEvent, render, screen, waitForEuiToolTipVisible } from 'uiSrc/utils/test-utils' +import { GZIP_COMPRESSED_VALUE_1, GZIP_COMPRESSED_VALUE_2, DECOMPRESSED_VALUE_STR_1, DECOMPRESSED_VALUE_STR_2 } from 'uiSrc/utils/tests/decompressors' +import { HashDetailsTable, Props } from './HashDetailsTable' + +const mockedProps = mock() +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] } }, +] + +jest.mock('uiSrc/slices/browser/hash', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState + return ({ + hashSelector: jest.fn().mockReturnValue(defaultState), + updateHashValueStateSelector: jest.fn().mockReturnValue(defaultState.updateValue), + hashDataSelector: jest.fn().mockReturnValue({ + ...defaultState, + total: 3, + key: '123zxczxczxc', + fields + }), + setHashInitialState: jest.fn, + fetchHashFields: () => jest.fn + }) +}) + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + compressor: null, + }), +})) + +describe('HashDetailsTable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render rows properly', () => { + const { container } = render() + const rows = container.querySelectorAll('.ReactVirtualized__Table__row[role="row"]') + expect(rows).toHaveLength(fields.length) + }) + + it('should render search input', () => { + render() + expect(screen.getByTestId('search')).toBeTruthy() + }) + + it('should call search', () => { + render() + const searchInput = screen.getByTestId('search') + fireEvent.change( + searchInput, + { target: { value: '*1*' } } + ) + expect(searchInput).toHaveValue('*1*') + }) + + it('should render delete popup after click remove button', () => { + render() + fireEvent.click(screen.getAllByTestId(/remove-hash-button/)[0]) + expect(screen.getByTestId(`remove-hash-button-${bufferToString(fields[0].field)}-icon`)).toBeInTheDocument() + }) + + it('should render editor after click edit button', () => { + render() + 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() + }) + + describe('decompressed data', () => { + it('should render decompressed GZIP data', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState + const hashDataSelectorMock = jest.fn().mockReturnValue({ + ...defaultState, + total: 1, + key: '123zxczxczxc', + fields: [ + { field: anyToBuffer(GZIP_COMPRESSED_VALUE_1), value: anyToBuffer(GZIP_COMPRESSED_VALUE_2) }, + ] + }) + hashDataSelector.mockImplementation(hashDataSelectorMock) + + const { queryByTestId, queryAllByTestId } = render() + const fieldEl = queryAllByTestId(/hash-field-/)?.[0] + const valueEl = queryByTestId(/hash-field-value/) + + expect(fieldEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) + expect(valueEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_2) + }) + + it('edit button should be disabled if data was compressed', async () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState + const hashDataSelectorMock = jest.fn().mockReturnValue({ + ...defaultState, + total: 1, + key: '123zxczxczxc', + fields: [ + { field: anyToBuffer(GZIP_COMPRESSED_VALUE_1), value: anyToBuffer(GZIP_COMPRESSED_VALUE_2) }, + ] + }) + hashDataSelector.mockImplementation(hashDataSelectorMock) + + connectedInstanceSelector.mockImplementation(() => ({ + compressor: KeyValueCompressor.GZIP, + })) + + const { queryByTestId } = render() + const editBtn = queryByTestId(/edit-hash-button/) + + fireEvent.click(editBtn) + + await act(async () => { + fireEvent.mouseOver(editBtn) + }) + await waitForEuiToolTipVisible() + + expect(editBtn).toBeDisabled() + expect(screen.getByTestId('hash-edit-tooltip')).toHaveTextContent(TEXT_DISABLED_COMPRESSED_VALUE) + expect(queryByTestId('hash-value-editor')).not.toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx new file mode 100644 index 0000000000..4cf7b5a787 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx @@ -0,0 +1,527 @@ +import { EuiButtonIcon, EuiProgress, EuiText, EuiTextArea, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import React, { ChangeEvent, Ref, useCallback, useEffect, useRef, useState } from 'react' +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_COMPRESSED_VALUE, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_FAILED_CONVENT_FORMATTER, + 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 { + deleteHashFields, + fetchHashFields, + fetchMoreHashFields, + 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, RedisString } from 'uiSrc/slices/interfaces' +import { getBasedOnViewTypeEvent, getMatchType, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { + bufferToSerializedFormat, + bufferToString, + createDeleteFieldHeader, + createDeleteFieldMessage, + formatLongName, + formattingBuffer, + isEqualBuffers, + isFormatEditable, + isNonUnicodeFormatter, + Nullable, + stringToSerializedBufferFormat +} from 'uiSrc/utils' +import { stringToBuffer } from 'uiSrc/utils/formatters/bufferFormatters' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' +import { AddFieldsToHashDto, GetHashFieldsResponse, HashFieldDto, } from 'apiSrc/modules/browser/dto/hash.dto' + +import PopoverDelete from '../../../../../components/popover-delete/PopoverDelete' +import styles from './styles.module.scss' + +const suffix = '_hash' +const matchAllValue = '*' +const headerHeight = 60 +const rowHeight = 43 + +const cellCache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: rowHeight, +}) + +interface IHashField extends HashFieldDto {} + +export interface Props { + isFooterOpen: boolean + onRemoveKey: () => void +} + +const HashDetailsTable = (props: Props) => { + const { isFooterOpen, onRemoveKey } = props + + const { + total, + nextCursor, + fields: loadedFields, + } = useSelector(hashDataSelector) + const { loading } = useSelector(hashSelector) + const { viewType } = useSelector(keysSelector) + const { id: instanceId, compressor = null } = useSelector(connectedInstanceSelector) + 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('') + const [fields, setFields] = useState([]) + const [editingIndex, setEditingIndex] = useState>(null) + const [width, setWidth] = useState(100) + const [expandedRows, setExpandedRows] = useState([]) + const [viewFormat, setViewFormat] = useState(viewFormatProp) + const [areaValue, setAreaValue] = useState('') + const [, forceUpdate] = useState({}) + + const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) + const textAreaRef: Ref = useRef(null) + + const dispatch = useDispatch() + + useEffect(() => { + setFields(loadedFields) + + if (loadedFields.length < fields.length) { + formattedLastIndexRef.current = 0 + } + + if (viewFormat !== viewFormatProp) { + setExpandedRows([]) + setViewFormat(viewFormatProp) + setEditingIndex(null) + + clearCache() + } + }, [loadedFields, viewFormatProp]) + + const clearCache = () => setTimeout(() => { + cellCache.clearAll() + forceUpdate({}) + }, 0) + + const closePopover = useCallback(() => { + setDeleting('') + }, []) + + const showPopover = useCallback((field = '') => { + setDeleting(`${field + suffix}`) + }, []) + + const onSuccessRemoved = (newTotalValue: number) => { + newTotalValue === 0 && onRemoveKey() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Hash, + numberOfRemoved: 1, + } + }) + } + + const handleDeleteField = (field: RedisString | string = '') => { + dispatch(deleteHashFields(key, [field], onSuccessRemoved)) + closePopover() + } + + const handleEditField = useCallback(( + rowIndex: Nullable = null, + editing: boolean, + valueItem?: RedisResponseBuffer + ) => { + setEditingIndex(editing ? rowIndex : null) + + if (editing) { + const value = bufferToSerializedFormat(viewFormat, valueItem, 4) + setAreaValue(value) + + setTimeout(() => { + textAreaRef?.current?.focus() + }, 0) + } + + // hack to update scrollbar padding + clearCache() + setTimeout(() => { + clearCache() + }, 0) + }, [viewFormat]) + + const handleApplyEditField = (field = '') => { + const data: AddFieldsToHashDto = { + keyName: key, + fields: [{ field, value: stringToSerializedBufferFormat(viewFormat, areaValue) }], + } + dispatch(updateHashFieldsAction(data, () => onHashEditedSuccess(field))) + } + + const onHashEditedSuccess = (fieldName = '') => { + handleEditField(fieldName, false) + } + + const handleRemoveIconClick = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Hash + } + }) + } + + const handleSearch = (search: IColumnSearchState[]) => { + const fieldColumn = search.find((column) => column.id === 'field') + if (fieldColumn) { + const { value: match } = fieldColumn + const onSuccess = (data: GetHashFieldsResponse) => { + const matchValue = getMatchType(match) + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Hash, + match: matchValue, + length: data.total, + } + }) + } + setMatch(match) + dispatch(fetchHashFields(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, true, onSuccess)) + } + } + + const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { + const browserViewEvent = expanded + ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED + const treeViewEvent = expanded + ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED + + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + keyType: KeyTypes.Hash, + databaseId: instanceId, + largestCellLength: Math.max(...Object.values(fields[rowIndex]).map((a) => a.toString().length)) || 0, + } + }) + + cellCache.clearAll() + } + + const loadMoreItems = () => { + if (nextCursor !== 0) { + dispatch( + fetchMoreHashFields( + key as RedisResponseBuffer, + nextCursor, + SCAN_COUNT_DEFAULT, + match || matchAllValue + ) + ) + } + } + + const updateTextAreaHeight = () => { + if (textAreaRef.current) { + textAreaRef.current.style.height = '0px' + textAreaRef.current.style.height = `${textAreaRef.current?.scrollHeight || 0}px` + } + } + + 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, + alignment: TableCellAlignment.Left, + className: 'value-table-separate-border', + headerClassName: 'value-table-separate-border', + render: (_name: string, { field: fieldItem }: HashFieldDto, expanded?: boolean) => { + const { value: decompressedItem } = decompressingBuffer(fieldItem, compressor) + const field = bufferToString(fieldItem) || '' + // Better to cut the long string, because it could affect virtual scroll performance + const tooltipContent = formatLongName(field) + const { value, isValid } = formattingBuffer(decompressedItem, viewFormatProp, { expanded }) + + return ( + +
+ {!expanded && ( + + <>{value?.substring?.(0, 200) ?? value} + + )} + {expanded && value} +
+
+ ) + }, + }, + { + id: 'value', + label: 'Value', + minWidth: 120, + truncateText: true, + alignment: TableCellAlignment.Left, + render: function Value( + _name: string, + { field: fieldItem, value: valueItem }: IHashField, + expanded?: boolean, + rowIndex = 0 + ) { + const { value: decompressedFieldItem } = decompressingBuffer(fieldItem, compressor) + const { value: decompressedValueItem } = decompressingBuffer(valueItem, compressor) + const value = bufferToString(valueItem) + const field = bufferToString(decompressedFieldItem) + // Better to cut the long string, because it could affect virtual scroll performance + const tooltipContent = formatLongName(value) + const { value: formattedValue, isValid } = formattingBuffer(decompressedValueItem, viewFormatProp, { expanded }) + + if (rowIndex === editingIndex) { + const disabled = !isNonUnicodeFormatter(viewFormat, isValid) + && !isEqualBuffers(valueItem, stringToBuffer(value)) + + setTimeout(() => cellCache.clear(rowIndex, 1), 0) + updateTextAreaHeight() + + return ( + setTimeout(updateTextAreaHeight, 0)}> + {({ width }) => ( +
+ + handleEditField(rowIndex, false)} + onApply={() => handleApplyEditField(fieldItem)} + approveText={TEXT_INVALID_VALUE} + approveByValidation={() => + formattingBuffer( + stringToSerializedBufferFormat(viewFormat, areaValue), + viewFormat + )?.isValid} + > + ) => { + cellCache.clearAll() + setAreaValue(e.target.value) + updateTextAreaHeight() + }} + disabled={updateLoading} + inputRef={textAreaRef} + className={cx(styles.textArea, { [styles.areaWarning]: disabled })} + spellCheck={false} + data-testid="hash-value-editor" + style={{ height: textAreaRef.current?.scrollHeight || 0 }} + /> + + +
+ )} +
+ ) + } + return ( + +
+ {!expanded && ( + + <>{formattedValue?.substring?.(0, 200) ?? formattedValue} + + )} + {expanded && formattedValue} +
+
+ ) + }, + }, + { + id: 'actions', + label: '', + headerClassName: 'value-table-header-actions', + className: 'actions', + absoluteWidth: 95, + minWidth: 95, + maxWidth: 95, + render: function Actions(_act: any, { field: fieldItem, value: valueItem }: HashFieldDto, _, rowIndex?: number) { + const field = bufferToString(fieldItem, viewFormat) + const { isCompressed } = decompressingBuffer(valueItem, compressor) + const isEditable = !isCompressed && isFormatEditable(viewFormat) + const tooltipContent = isCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING + return ( + +
+ + handleEditField(rowIndex, true, valueItem)} + data-testid={`edit-hash-button-${field}`} + /> + + +
+
+ ) + }, + }, + ] + + return ( + <> +
+ {loading && ( + + )} + ({ + ...column, + width: getColumnWidth(i, width, arr) + }))} + footerHeight={0} + overscanRowCount={10} + loadMoreItems={loadMoreItems} + loading={loading} + items={fields} + totalItemsCount={total} + noItemsMessage={NoResultsFoundText} + onWheel={closePopover} + onSearch={handleSearch} + cellCache={cellCache} + onRowToggleViewClick={handleRowToggleViewClick} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + onColResizeEnd={onColResizeEnd} + /> +
+ + ) +} + +export { HashDetailsTable } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/index.ts new file mode 100644 index 0000000000..5caacf2fc2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/index.ts @@ -0,0 +1 @@ +export { HashDetailsTable } from './HashDetailsTable' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/index.ts new file mode 100644 index 0000000000..f6b0b8f2e2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/index.ts @@ -0,0 +1 @@ +export { HashDetails } from './HashDetails' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx index fe9f060308..564750e0d4 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { instance, mock } from 'ts-mockito' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import AddListElements, { HEAD_DESTINATION, Props } from './AddListElements' const mockedProps = mock() diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.spec.tsx index a7ac645569..7158faf4a0 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.spec.tsx @@ -1,132 +1,12 @@ import React from 'react' -import { mock } from 'ts-mockito' -import { KeyValueCompressor, TEXT_DISABLED_COMPRESSED_VALUE } from 'uiSrc/constants' -import { listDataSelector } from 'uiSrc/slices/browser/list' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { anyToBuffer } from 'uiSrc/utils' -import { act, fireEvent, render, screen, waitForEuiToolTipVisible } from 'uiSrc/utils/test-utils' -import { GZIP_COMPRESSED_VALUE_1, DECOMPRESSED_VALUE_STR_1 } from 'uiSrc/utils/tests/decompressors' -import ListDetails, { Props } from './ListDetails' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, ListDetails } from './ListDetails' const mockedProps = mock() -const elements = [ - { element: { type: 'Buffer', data: [49] }, index: 0 }, - { element: { type: 'Buffer', data: [50] }, index: 1 }, - { element: { type: 'Buffer', data: [51] }, index: 2 }, -] - -jest.mock('uiSrc/slices/browser/list', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState - return { - listSelector: jest.fn().mockReturnValue(defaultState), - updateListValueStateSelector: jest - .fn() - .mockReturnValue(defaultState.updateValue), - listDataSelector: jest.fn().mockReturnValue({ - ...defaultState, - total: 3, - key: { type: 'Buffer', data: [49] }, - keyName: { type: 'Buffer', data: [49] }, - elements, - }), - fetchListElements: jest.fn(), - fetchSearchingListElementAction: jest.fn, - setListInitialState: jest.fn, - } -}) - -jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - connectedInstanceSelector: jest.fn().mockReturnValue({ - compressor: null, - }), -})) - describe('ListDetails', () => { it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('should render rows properly', () => { - const { container } = render() - const rows = container.querySelectorAll( - '.ReactVirtualized__Table__row[role="row"]' - ) - expect(rows).toHaveLength(elements.length) - }) - - it('should render search input', () => { - render() - expect(screen.getByTestId('search')).toBeTruthy() - }) - - it('should call search', () => { - 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() - 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() - }) - - describe('decompressed data', () => { - it('should render decompressed GZIP data = "1"', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState - const listDataSelectorMock = jest.fn().mockReturnValue({ - ...defaultState, - key: '123zxczxczxc', - elements: [ - { element: anyToBuffer(GZIP_COMPRESSED_VALUE_1), index: 0 }, - ] - }) - listDataSelector.mockImplementation(listDataSelectorMock) - - const { queryByTestId } = render() - const elementEl = queryByTestId(/list-element-value-/) - - expect(elementEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) - }) - - it('edit button should be disabled if data was compressed', async () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState - const listDataSelectorMock = jest.fn().mockReturnValue({ - ...defaultState, - key: '123zxczxczxc', - elements: [ - { element: anyToBuffer(GZIP_COMPRESSED_VALUE_1), index: 0 }, - ] - }) - listDataSelector.mockImplementation(listDataSelectorMock) - - connectedInstanceSelector.mockImplementation(() => ({ - compressor: KeyValueCompressor.GZIP, - })) - - const { queryByTestId } = render() - const editBtn = queryByTestId(/edit-list-button-/) - - fireEvent.click(editBtn) - - await act(async () => { - fireEvent.mouseOver(editBtn) - }) - await waitForEuiToolTipVisible() - - expect(editBtn).toBeDisabled() - expect(screen.getByTestId('list-edit-tooltip')).toHaveTextContent(TEXT_DISABLED_COMPRESSED_VALUE) - expect(queryByTestId('list-value-editor')).not.toBeInTheDocument() - }) + expect(render()).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx index c049d825a3..817694e0fd 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx @@ -1,463 +1,86 @@ -import { EuiButtonIcon, EuiProgress, EuiText, EuiTextArea, EuiToolTip } from '@elastic/eui' -import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import React, { useState } from 'react' +import { useSelector } from 'react-redux' 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, - listDataSelector, - fetchListElements, - fetchMoreListElements, - updateListElementAction, - updateListValueStateSelector, - fetchSearchingListElementAction, -} from 'uiSrc/slices/browser/list' -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' -import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' -import { - KeyTypes, - OVER_RENDER_BUFFER_COUNT, - TableCellAlignment, - TEXT_INVALID_VALUE, - TEXT_DISABLED_FORMATTER_EDITING, - TEXT_UNPRINTABLE_CHARACTERS, - TEXT_DISABLED_COMPRESSED_VALUE, - TEXT_FAILED_CONVENT_FORMATTER, -} from 'uiSrc/constants' -import { - bufferToSerializedFormat, - bufferToString, - formatLongName, - formattingBuffer, - isFormatEditable, - isNonUnicodeFormatter, - isEqualBuffers, - stringToBuffer, - stringToSerializedBufferFormat, - validateListIndex, - Nullable -} from 'uiSrc/utils' -import { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { NoResultsFoundText } from 'uiSrc/constants/texts' -import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' -import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' -import { StopPropagation } from 'uiSrc/components/virtual-table' -import { getColumnWidth } from 'uiSrc/components/virtual-grid' -import { decompressingBuffer } from 'uiSrc/utils/decompressors' + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes } from 'uiSrc/constants' -import { - SetListElementDto, - SetListElementResponse, -} from 'apiSrc/modules/browser/dto' +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' +import { ListDetailsTable } from './list-details-table' -import styles from './styles.module.scss' +import { AddItemsPanel } from '../add-items-panel' +import { RemoveListElements } from '../key-details-remove-items' -const headerHeight = 60 -const rowHeight = 43 -const footerHeight = 0 -const initSearchingIndex = null - -const cellCache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: rowHeight, -}) - -interface IListElement extends SetListElementResponse {} +import styles from './styles.module.scss' -export interface Props { - isFooterOpen: boolean +export interface Props extends KeyDetailsHeaderProps { + onRemoveKey: () => void + onOpenAddItemPanel: () => void + onCloseAddItemPanel: () => void } const ListDetails = (props: Props) => { - const { isFooterOpen } = props - const { loading } = useSelector(listSelector) - const { loading: updateLoading } = useSelector(updateListValueStateSelector) - const { elements: loadedElements, total, searchedIndex } = useSelector( - listDataSelector - ) - const { name: key } = useSelector(selectedKeyDataSelector) ?? { name: '' } - const { id: instanceId, compressor = null } = 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) - const [expandedRows, setExpandedRows] = useState([]) - const [editingIndex, setEditingIndex] = useState>(null) - const [viewFormat, setViewFormat] = useState(viewFormatProp) - const [areaValue, setAreaValue] = useState('') - const [, forceUpdate] = useState({}) - - const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) - const textAreaRef: React.Ref = useRef(null) - - const dispatch = useDispatch() - - useEffect(() => { - setElements(loadedElements) - - if (loadedElements.length < elements.length) { - formattedLastIndexRef.current = 0 - } - - if (viewFormat !== viewFormatProp) { - setExpandedRows([]) - setViewFormat(viewFormatProp) - setEditingIndex(null) - - clearCache() - } - }, [loadedElements, viewFormatProp]) - - const clearCache = () => setTimeout(() => { - cellCache.clearAll() - forceUpdate({}) - }, 0) - - const handleEditElement = useCallback(( - index: Nullable = null, - editing: boolean, - valueItem?: RedisResponseBuffer - ) => { - setEditingIndex(editing ? index : null) + const keyType = KeyTypes.List + const { onRemoveKey, onOpenAddItemPanel, onCloseAddItemPanel } = props + const { loading } = useSelector(selectedKeySelector) - if (editing) { - const value = bufferToSerializedFormat(viewFormat, valueItem, 4) - setAreaValue(value) + const [isRemoveItemPanelOpen, setIsRemoveItemPanelOpen] = useState(false) + const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) - setTimeout(() => { - textAreaRef?.current?.focus() - }, 0) - } - - // hack to update scrollbar padding - clearCache() - setTimeout(() => { - clearCache() - }, 0) - }, [cellCache, viewFormat]) - - const handleApplyEditElement = (index = 0) => { - const data: SetListElementDto = { - keyName: key, - element: stringToSerializedBufferFormat(viewFormat, areaValue), - index, - } - dispatch( - updateListElementAction(data, () => onElementEditedSuccess(index)) - ) + const openAddItemPanel = () => { + setIsRemoveItemPanelOpen(false) + setIsAddItemPanelOpen(true) + onOpenAddItemPanel() } - const onElementEditedSuccess = (elementIndex = 0) => { - handleEditElement(elementIndex, false) + const closeAddItemPanel = () => { + setIsAddItemPanelOpen(false) + onCloseAddItemPanel() } - const handleSearch = (search: IColumnSearchState[]) => { - formattedLastIndexRef.current = 0 - const indexColumn = search.find((column) => column.id === 'index') - const onSuccess = () => { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.List, - match: 'EXACT_VALUE_NAME', - } - }) - } - - if (!indexColumn?.value) { - dispatch(fetchListElements(key, 0, SCAN_COUNT_DEFAULT)) - return - } - - if (indexColumn) { - const { value } = indexColumn - dispatch( - fetchSearchingListElementAction( - key, - value ? +value : initSearchingIndex, - onSuccess - ) - ) - } + const closeRemoveItemPanel = () => { + setIsRemoveItemPanelOpen(false) } - const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { - const browserViewEvent = expanded - ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED - const treeViewEvent = expanded - ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED - - sendEventTelemetry({ - event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), - eventData: { - keyType: KeyTypes.List, - databaseId: instanceId, - largestCellLength: elements[rowIndex]?.element?.length || 0, - } - }) - - cellCache.clearAll() - } - - const updateTextAreaHeight = () => { - if (textAreaRef.current) { - textAreaRef.current.style.height = '0px' - textAreaRef.current.style.height = `${textAreaRef.current?.scrollHeight || 0}px` - } - } - - const onColResizeEnd = (sizes: RelativeWidthSizes) => { - dispatch(updateKeyDetailsSizes({ - type: KeyTypes.List, - sizes - })) - } - - const columns: ITableColumn[] = [ - { - id: 'index', - label: 'Index', - minWidth: 120, - relativeWidth: listSizes?.index || 30, - truncateText: true, - isSearchable: true, - isResizable: true, - prependSearchName: 'Index:', - initialSearchValue: '', - searchValidation: validateListIndex, - className: 'value-table-separate-border', - headerClassName: 'value-table-separate-border', - render: function Index(_name: string, { index }: IListElement) { - // Better to cut the long string, because it could affect virtual scroll performance - const cellContent = index?.toString().substring(0, 200) - const tooltipContent = formatLongName(index?.toString()) - return ( - -
- - <>{cellContent} - -
-
- ) - }, - }, - { - id: 'element', - label: 'Element', - minWidth: 150, - truncateText: true, - alignment: TableCellAlignment.Left, - render: function Element( - _element: string, - { element: elementItem, index }: IListElement, - expanded: boolean = false, - rowIndex = 0 - ) { - const { value: decompressedElementItem } = decompressingBuffer(elementItem, compressor) - const element = bufferToString(elementItem) - const tooltipContent = formatLongName(element) - const { value, isValid } = formattingBuffer(decompressedElementItem, viewFormatProp, { expanded }) - - if (index === editingIndex) { - const disabled = !isNonUnicodeFormatter(viewFormat, isValid) - && !isEqualBuffers(elementItem, stringToBuffer(element)) - - setTimeout(() => cellCache.clear(rowIndex, 1), 0) - - return ( - setTimeout(updateTextAreaHeight, 0)}> - {({ width }) => ( -
- -
- handleEditElement(index, false)} - onApply={() => handleApplyEditElement(index)} - approveText={TEXT_INVALID_VALUE} - approveByValidation={() => - formattingBuffer( - stringToSerializedBufferFormat(viewFormat, areaValue), - viewFormat - )?.isValid} - > - ) => { - cellCache.clearAll() - setAreaValue(e.target.value) - updateTextAreaHeight() - }} - disabled={updateLoading} - inputRef={textAreaRef} - className={cx(styles.textArea, { [styles.areaWarning]: disabled })} - spellCheck={false} - data-testid="element-value-editor" - style={{ height: textAreaRef.current?.scrollHeight || 0 }} - /> - -
-
-
- )} -
- ) - } - return ( - -
- {!expanded && ( - - <>{value?.substring?.(0, 200) ?? value} - - )} - {expanded && value} -
-
- ) - }, - }, - { - id: 'actions', - label: '', - headerClassName: 'value-table-header-actions', - className: 'actions', - minWidth: 60, - maxWidth: 60, - absoluteWidth: 60, - render: function Actions(_element: any, { index, element }: IListElement) { - const { isCompressed } = decompressingBuffer(element, compressor) - const isEditable = !isCompressed && isFormatEditable(viewFormat) - const tooltipContent = isCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING - return ( - -
- - handleEditElement(index, true, element)} - data-testid={`edit-list-button-${index}`} - /> - -
-
- ) - }, - }, - ] - - const loadMoreItems = ({ startIndex, stopIndex }: any) => { - if (isNull(searchedIndex)) { - dispatch( - fetchMoreListElements(key, startIndex, stopIndex - startIndex + 1) - ) - } + const openRemoveItemPanel = () => { + setIsAddItemPanelOpen(false) + setIsRemoveItemPanelOpen(true) } return ( -
- {loading && ( - - )} - ({ - ...column, - width: getColumnWidth(i, width, arr) - }))} - loadMoreItems={loadMoreItems} - loading={loading} - items={elements} - totalItemsCount={total} - noItemsMessage={NoResultsFoundText} - onSearch={handleSearch} - cellCache={cellCache} - onRowToggleViewClick={handleRowToggleViewClick} - expandedRows={expandedRows} - setExpandedRows={setExpandedRows} - onColResizeEnd={onColResizeEnd} +
+ +
+ {!loading && ( +
+ +
+ )} + {isAddItemPanelOpen && ( + + )} + {isRemoveItemPanelOpen && ( +
+ {keyType === KeyTypes.List && ( + + )} +
+ )} +
+ ) } -export default ListDetails +export { ListDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/index.ts new file mode 100644 index 0000000000..b0163cac5f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/index.ts @@ -0,0 +1 @@ +export { ListDetails } from './ListDetails' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.spec.tsx new file mode 100644 index 0000000000..fdd4b0438c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.spec.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { KeyValueCompressor, TEXT_DISABLED_COMPRESSED_VALUE } from 'uiSrc/constants' +import { listDataSelector } from 'uiSrc/slices/browser/list' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { anyToBuffer } from 'uiSrc/utils' +import { act, fireEvent, render, screen, waitForEuiToolTipVisible } from 'uiSrc/utils/test-utils' +import { GZIP_COMPRESSED_VALUE_1, DECOMPRESSED_VALUE_STR_1 } from 'uiSrc/utils/tests/decompressors' +import { ListDetailsTable, Props } from './ListDetailsTable' + +const mockedProps = mock() + +const elements = [ + { element: { type: 'Buffer', data: [49] }, index: 0 }, + { element: { type: 'Buffer', data: [50] }, index: 1 }, + { element: { type: 'Buffer', data: [51] }, index: 2 }, +] + +jest.mock('uiSrc/slices/browser/list', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState + return { + listSelector: jest.fn().mockReturnValue(defaultState), + updateListValueStateSelector: jest + .fn() + .mockReturnValue(defaultState.updateValue), + listDataSelector: jest.fn().mockReturnValue({ + ...defaultState, + total: 3, + key: { type: 'Buffer', data: [49] }, + keyName: { type: 'Buffer', data: [49] }, + elements, + }), + fetchListElements: jest.fn(), + fetchSearchingListElementAction: jest.fn, + setListInitialState: jest.fn, + } +}) + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + compressor: null, + }), +})) + +describe('ListDetailsTable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render rows properly', () => { + const { container } = render() + const rows = container.querySelectorAll( + '.ReactVirtualized__Table__row[role="row"]' + ) + expect(rows).toHaveLength(elements.length) + }) + + it('should render search input', () => { + render() + expect(screen.getByTestId('search')).toBeTruthy() + }) + + it('should call search', () => { + 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() + 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() + }) + + describe('decompressed data', () => { + it('should render decompressed GZIP data = "1"', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState + const listDataSelectorMock = jest.fn().mockReturnValue({ + ...defaultState, + key: '123zxczxczxc', + elements: [ + { element: anyToBuffer(GZIP_COMPRESSED_VALUE_1), index: 0 }, + ] + }) + listDataSelector.mockImplementation(listDataSelectorMock) + + const { queryByTestId } = render() + const elementEl = queryByTestId(/list-element-value-/) + + expect(elementEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) + }) + + it('edit button should be disabled if data was compressed', async () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState + const listDataSelectorMock = jest.fn().mockReturnValue({ + ...defaultState, + key: '123zxczxczxc', + elements: [ + { element: anyToBuffer(GZIP_COMPRESSED_VALUE_1), index: 0 }, + ] + }) + listDataSelector.mockImplementation(listDataSelectorMock) + + connectedInstanceSelector.mockImplementation(() => ({ + compressor: KeyValueCompressor.GZIP, + })) + + const { queryByTestId } = render() + const editBtn = queryByTestId(/edit-list-button-/) + + fireEvent.click(editBtn) + + await act(async () => { + fireEvent.mouseOver(editBtn) + }) + await waitForEuiToolTipVisible() + + expect(editBtn).toBeDisabled() + expect(screen.getByTestId('list-edit-tooltip')).toHaveTextContent(TEXT_DISABLED_COMPRESSED_VALUE) + expect(queryByTestId('list-value-editor')).not.toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.tsx new file mode 100644 index 0000000000..948a8f094e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/ListDetailsTable.tsx @@ -0,0 +1,463 @@ +import { EuiButtonIcon, EuiProgress, EuiText, EuiTextArea, EuiToolTip } from '@elastic/eui' +import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +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, + listDataSelector, + fetchListElements, + fetchMoreListElements, + updateListElementAction, + updateListValueStateSelector, + fetchSearchingListElementAction, +} from 'uiSrc/slices/browser/list' +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' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' +import { + KeyTypes, + OVER_RENDER_BUFFER_COUNT, + TableCellAlignment, + TEXT_INVALID_VALUE, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_UNPRINTABLE_CHARACTERS, + TEXT_DISABLED_COMPRESSED_VALUE, + TEXT_FAILED_CONVENT_FORMATTER, +} from 'uiSrc/constants' +import { + bufferToSerializedFormat, + bufferToString, + formatLongName, + formattingBuffer, + isFormatEditable, + isNonUnicodeFormatter, + isEqualBuffers, + stringToBuffer, + stringToSerializedBufferFormat, + validateListIndex, + Nullable +} from 'uiSrc/utils' +import { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { NoResultsFoundText } from 'uiSrc/constants/texts' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { StopPropagation } from 'uiSrc/components/virtual-table' +import { getColumnWidth } from 'uiSrc/components/virtual-grid' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' + +import { + SetListElementDto, + SetListElementResponse, +} from 'apiSrc/modules/browser/dto' + +import styles from './styles.module.scss' + +const headerHeight = 60 +const rowHeight = 43 +const footerHeight = 0 +const initSearchingIndex = null + +const cellCache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: rowHeight, +}) + +interface IListElement extends SetListElementResponse {} + +export interface Props { + isFooterOpen: boolean +} + +const ListDetailsTable = (props: Props) => { + const { isFooterOpen } = props + const { loading } = useSelector(listSelector) + const { loading: updateLoading } = useSelector(updateListValueStateSelector) + const { elements: loadedElements, total, searchedIndex } = useSelector( + listDataSelector + ) + const { name: key } = useSelector(selectedKeyDataSelector) ?? { name: '' } + const { id: instanceId, compressor = null } = 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) + const [expandedRows, setExpandedRows] = useState([]) + const [editingIndex, setEditingIndex] = useState>(null) + const [viewFormat, setViewFormat] = useState(viewFormatProp) + const [areaValue, setAreaValue] = useState('') + const [, forceUpdate] = useState({}) + + const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) + const textAreaRef: React.Ref = useRef(null) + + const dispatch = useDispatch() + + useEffect(() => { + setElements(loadedElements) + + if (loadedElements.length < elements.length) { + formattedLastIndexRef.current = 0 + } + + if (viewFormat !== viewFormatProp) { + setExpandedRows([]) + setViewFormat(viewFormatProp) + setEditingIndex(null) + + clearCache() + } + }, [loadedElements, viewFormatProp]) + + const clearCache = () => setTimeout(() => { + cellCache.clearAll() + forceUpdate({}) + }, 0) + + const handleEditElement = useCallback(( + index: Nullable = null, + editing: boolean, + valueItem?: RedisResponseBuffer + ) => { + setEditingIndex(editing ? index : null) + + if (editing) { + const value = bufferToSerializedFormat(viewFormat, valueItem, 4) + setAreaValue(value) + + setTimeout(() => { + textAreaRef?.current?.focus() + }, 0) + } + + // hack to update scrollbar padding + clearCache() + setTimeout(() => { + clearCache() + }, 0) + }, [cellCache, viewFormat]) + + const handleApplyEditElement = (index = 0) => { + const data: SetListElementDto = { + keyName: key, + element: stringToSerializedBufferFormat(viewFormat, areaValue), + index, + } + dispatch( + updateListElementAction(data, () => onElementEditedSuccess(index)) + ) + } + + const onElementEditedSuccess = (elementIndex = 0) => { + handleEditElement(elementIndex, false) + } + + const handleSearch = (search: IColumnSearchState[]) => { + formattedLastIndexRef.current = 0 + const indexColumn = search.find((column) => column.id === 'index') + const onSuccess = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.List, + match: 'EXACT_VALUE_NAME', + } + }) + } + + if (!indexColumn?.value) { + dispatch(fetchListElements(key, 0, SCAN_COUNT_DEFAULT)) + return + } + + if (indexColumn) { + const { value } = indexColumn + dispatch( + fetchSearchingListElementAction( + key, + value ? +value : initSearchingIndex, + onSuccess + ) + ) + } + } + + const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { + const browserViewEvent = expanded + ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED + const treeViewEvent = expanded + ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED + + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + keyType: KeyTypes.List, + databaseId: instanceId, + largestCellLength: elements[rowIndex]?.element?.length || 0, + } + }) + + cellCache.clearAll() + } + + const updateTextAreaHeight = () => { + if (textAreaRef.current) { + textAreaRef.current.style.height = '0px' + textAreaRef.current.style.height = `${textAreaRef.current?.scrollHeight || 0}px` + } + } + + const onColResizeEnd = (sizes: RelativeWidthSizes) => { + dispatch(updateKeyDetailsSizes({ + type: KeyTypes.List, + sizes + })) + } + + const columns: ITableColumn[] = [ + { + id: 'index', + label: 'Index', + minWidth: 120, + relativeWidth: listSizes?.index || 30, + truncateText: true, + isSearchable: true, + isResizable: true, + prependSearchName: 'Index:', + initialSearchValue: '', + searchValidation: validateListIndex, + className: 'value-table-separate-border', + headerClassName: 'value-table-separate-border', + render: function Index(_name: string, { index }: IListElement) { + // Better to cut the long string, because it could affect virtual scroll performance + const cellContent = index?.toString().substring(0, 200) + const tooltipContent = formatLongName(index?.toString()) + return ( + +
+ + <>{cellContent} + +
+
+ ) + }, + }, + { + id: 'element', + label: 'Element', + minWidth: 150, + truncateText: true, + alignment: TableCellAlignment.Left, + render: function Element( + _element: string, + { element: elementItem, index }: IListElement, + expanded: boolean = false, + rowIndex = 0 + ) { + const { value: decompressedElementItem } = decompressingBuffer(elementItem, compressor) + const element = bufferToString(elementItem) + const tooltipContent = formatLongName(element) + const { value, isValid } = formattingBuffer(decompressedElementItem, viewFormatProp, { expanded }) + + if (index === editingIndex) { + const disabled = !isNonUnicodeFormatter(viewFormat, isValid) + && !isEqualBuffers(elementItem, stringToBuffer(element)) + + setTimeout(() => cellCache.clear(rowIndex, 1), 0) + + return ( + setTimeout(updateTextAreaHeight, 0)}> + {({ width }) => ( +
+ +
+ handleEditElement(index, false)} + onApply={() => handleApplyEditElement(index)} + approveText={TEXT_INVALID_VALUE} + approveByValidation={() => + formattingBuffer( + stringToSerializedBufferFormat(viewFormat, areaValue), + viewFormat + )?.isValid} + > + ) => { + cellCache.clearAll() + setAreaValue(e.target.value) + updateTextAreaHeight() + }} + disabled={updateLoading} + inputRef={textAreaRef} + className={cx(styles.textArea, { [styles.areaWarning]: disabled })} + spellCheck={false} + data-testid="element-value-editor" + style={{ height: textAreaRef.current?.scrollHeight || 0 }} + /> + +
+
+
+ )} +
+ ) + } + return ( + +
+ {!expanded && ( + + <>{value?.substring?.(0, 200) ?? value} + + )} + {expanded && value} +
+
+ ) + }, + }, + { + id: 'actions', + label: '', + headerClassName: 'value-table-header-actions', + className: 'actions', + minWidth: 60, + maxWidth: 60, + absoluteWidth: 60, + render: function Actions(_element: any, { index, element }: IListElement) { + const { isCompressed } = decompressingBuffer(element, compressor) + const isEditable = !isCompressed && isFormatEditable(viewFormat) + const tooltipContent = isCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING + return ( + +
+ + handleEditElement(index, true, element)} + data-testid={`edit-list-button-${index}`} + /> + +
+
+ ) + }, + }, + ] + + const loadMoreItems = ({ startIndex, stopIndex }: any) => { + if (isNull(searchedIndex)) { + dispatch( + fetchMoreListElements(key, startIndex, stopIndex - startIndex + 1) + ) + } + } + + return ( +
+ {loading && ( + + )} + ({ + ...column, + width: getColumnWidth(i, width, arr) + }))} + loadMoreItems={loadMoreItems} + loading={loading} + items={elements} + totalItemsCount={total} + noItemsMessage={NoResultsFoundText} + onSearch={handleSearch} + cellCache={cellCache} + onRowToggleViewClick={handleRowToggleViewClick} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + onColResizeEnd={onColResizeEnd} + /> +
+ ) +} + +export { ListDetailsTable } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/index.ts new file mode 100644 index 0000000000..de061cae84 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/index.ts @@ -0,0 +1 @@ +export { ListDetailsTable } from './ListDetailsTable' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/styles.module.scss new file mode 100644 index 0000000000..635d5674ee --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/list-details-table/styles.module.scss @@ -0,0 +1,41 @@ +.container { + display: flex; + flex: 1; + width: 100%; + padding: 16px; + background-color: var(--euiColorEmptyShade); + + .textAreaControls { + right: -56px !important; + bottom: -4px !important; + top: auto !important; + background-color: var(--euiPageBackgroundColor) !important; + } + + .textArea { + background-color: var(--euiPageBackgroundColor) !important; + border-color: var(--euiColorPrimary) !important; + z-index: 3; + padding-left: 20px; + padding-bottom: 36px !important; + margin: -8px -6px -8px -20px !important; + min-width: calc(100% + 106px) !important; + font: normal normal normal 13px/18px Graphik, sans-serif; + min-height: 43px; + overflow: hidden; + overflow-wrap: break-word; + resize: none; + + &:focus { + background-image: none !important; + } + + &.areaWarning { + border-color: var(--euiColorWarningLight) !important; + } + } +} + +.inlineItemEditor { + max-width: calc(100% - 20px); +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/styles.module.scss index 635d5674ee..0838dd17dc 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/styles.module.scss @@ -1,41 +1,4 @@ -.container { - display: flex; - flex: 1; - width: 100%; - padding: 16px; - background-color: var(--euiColorEmptyShade); - - .textAreaControls { - right: -56px !important; - bottom: -4px !important; - top: auto !important; - background-color: var(--euiPageBackgroundColor) !important; - } - - .textArea { - background-color: var(--euiPageBackgroundColor) !important; - border-color: var(--euiColorPrimary) !important; - z-index: 3; - padding-left: 20px; - padding-bottom: 36px !important; - margin: -8px -6px -8px -20px !important; - min-width: calc(100% + 106px) !important; - font: normal normal normal 13px/18px Graphik, sans-serif; - min-height: 43px; - overflow: hidden; - overflow-wrap: break-word; - resize: none; - - &:focus { - background-image: none !important; - } - - &.areaWarning { - border-color: var(--euiColorWarningLight) !important; - } - } -} - -.inlineItemEditor { - max-width: calc(100% - 20px); -} +.contentActive { + border-color: var(--euiColorPrimary) !important; + border-bottom-width: 1px !important; +} \ No newline at end of file diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.spec.tsx index 3053b97b69..9b87018a71 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.spec.tsx @@ -1,9 +1,12 @@ import React from 'react' +import { instance, mock } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' -import RejsonDetailsWrapper from './RejsonDetailsWrapper' +import { RejsonDetailsWrapper, Props } from './RejsonDetailsWrapper' + +const mockedProps = mock() describe('ReJSONDetailsWrapper', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx index 833d02f369..da31e54606 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx @@ -6,12 +6,17 @@ import { rejsonDataSelector, rejsonSelector } from 'uiSrc/slices/browser/rejson' import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' +import { KeyTypes } from 'uiSrc/constants' import RejsonDetails from './RejsonDetails/RejsonDetails' import styles from './styles.module.scss' -const RejsonDetailsWrapper = () => { +export interface Props extends KeyDetailsHeaderProps {} + +const RejsonDetailsWrapper = (props: Props) => { + const keyType = KeyTypes.ReJSON const { loading } = useSelector(rejsonSelector) const { data, downloaded, type } = useSelector(rejsonDataSelector) const { name: selectedKey = '' } = useSelector(selectedKeyDataSelector) || {} @@ -67,41 +72,54 @@ const RejsonDetailsWrapper = () => { const reportJSONPropertyAdded = () => {} return ( -
- {loading && ( - - )} - {!(loading && data === undefined) && ( - {}} - handleOpenExpiryDialog={() => {}} - keyProperty={{}} - /> - )} +
+ +
+ {!loading && ( +
+
+ {loading && ( + + )} + {!(loading && data === undefined) && ( + {}} + handleOpenExpiryDialog={() => {}} + keyProperty={{}} + /> + )} +
+
+ )} +
) } -export default RejsonDetailsWrapper +export { RejsonDetailsWrapper } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/index.ts new file mode 100644 index 0000000000..fa6bc0e31c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/rejson-details/index.ts @@ -0,0 +1 @@ +export { RejsonDetailsWrapper } from './RejsonDetailsWrapper' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.spec.tsx index cf122e8492..fd89ca6739 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.spec.tsx @@ -1,81 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { setDataSelector } from 'uiSrc/slices/browser/set' -import { anyToBuffer } from 'uiSrc/utils' -import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import { GZIP_COMPRESSED_VALUE_1, DECOMPRESSED_VALUE_STR_1 } from 'uiSrc/utils/tests/decompressors' -import SetDetails, { Props } from './SetDetails' +import { render } from 'uiSrc/utils/test-utils' +import { Props, SetDetails } from './SetDetails' -const members = [ - { type: 'Buffer', data: [49] }, - { type: 'Buffer', data: [50] }, - { type: 'Buffer', data: [51] }, -] const mockedProps = mock() -jest.mock('uiSrc/slices/browser/set', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/set').initialState - return ({ - setSelector: jest.fn().mockReturnValue(defaultState), - setDataSelector: jest.fn().mockReturnValue({ - ...defaultState, - total: 3, - key: { type: 'Buffer', data: [49] }, - keyName: { type: 'Buffer', data: [49] }, - members, - }), - fetchSetMembers: () => jest.fn() - }) -}) - describe('SetDetails', () => { it('should render', () => { expect(render()).toBeTruthy() }) - - it('should render rows properly', () => { - const { container } = render() - const rows = container.querySelectorAll('.ReactVirtualized__Table__row[role="row"]') - expect(rows).toHaveLength(members.length) - }) - - it('should render search input', () => { - render() - expect(screen.getByTestId('search')).toBeTruthy() - }) - - it('should call search', () => { - render() - const searchInput = screen.getByTestId('search') - fireEvent.change( - searchInput, - { target: { value: '*1*' } } - ) - expect(searchInput).toHaveValue('*1*') - }) - - it('should render delete popup after click remove button', () => { - render() - fireEvent.click(screen.getAllByTestId(/set-remove-btn/)[0]) - expect(screen.getByTestId(/set-remove-btn-1-icon/)).toBeInTheDocument() - }) - - describe('decompressed data', () => { - it('should render decompressed GZIP data = "1"', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/set').initialState - const setDataSelectorMock = jest.fn().mockReturnValue({ - ...defaultState, - key: '123zxczxczxc', - members: [ - anyToBuffer(GZIP_COMPRESSED_VALUE_1), - ] - }) - setDataSelector.mockImplementation(setDataSelectorMock) - - const { queryByTestId } = render() - const memberEl = queryByTestId(/set-member-value-/) - - expect(memberEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) - }) - }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx index 2626732a02..3aad145c8d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx @@ -1,314 +1,64 @@ -import React, { useEffect, useRef, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' -import { - EuiProgress, - EuiText, - EuiToolTip, -} from '@elastic/eui' -import { CellMeasurerCache } from 'react-virtualized' -import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' +import React, { useState } from 'react' +import { useSelector } from 'react-redux' import { - bufferToString, - createDeleteFieldHeader, - createDeleteFieldMessage, - formatLongName, - formattingBuffer, -} from 'uiSrc/utils' -import { KeyTypes, OVER_RENDER_BUFFER_COUNT, TEXT_FAILED_CONVENT_FORMATTER } from 'uiSrc/constants' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { - deleteSetMembers, - fetchSetMembers, - fetchMoreSetMembers, - setDataSelector, - setSelector, -} from 'uiSrc/slices/browser/set' -import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' -import HelpTexts from 'uiSrc/constants/help-texts' -import { NoResultsFoundText } from 'uiSrc/constants/texts' -import VirtualTable from 'uiSrc/components/virtual-table' -import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' -import { getColumnWidth } from 'uiSrc/components/virtual-grid' -import { IColumnSearchState, ITableColumn } from 'uiSrc/components/virtual-table/interfaces' -import { decompressingBuffer } from 'uiSrc/utils/decompressors' -import { GetSetMembersResponse } from 'apiSrc/modules/browser/dto/set.dto' -import styles from './styles.module.scss' + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes } from 'uiSrc/constants' -const suffix = '_set' -const headerHeight = 60 -const rowHeight = 43 -const footerHeight = 0 -const matchAllValue = '*' +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' +import { SetDetailsTable } from './set-details-table' -const cellCache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: rowHeight, -}) +import { AddItemsPanel } from '../add-items-panel' -export interface Props { - isFooterOpen: boolean +export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void + onOpenAddItemPanel: () => void + onCloseAddItemPanel: () => void } const SetDetails = (props: Props) => { - const { isFooterOpen, onRemoveKey } = props - - const { loading } = useSelector(setSelector) - const { members: loadedMembers, total, nextCursor } = useSelector(setDataSelector) - const { length = 0, name: key } = useSelector(selectedKeyDataSelector) ?? {} - const { id: instanceId, compressor = null } = useSelector(connectedInstanceSelector) - const { viewType } = useSelector(keysSelector) - const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) - - const [match, setMatch] = useState('*') - const [deleting, setDeleting] = useState('') - const [width, setWidth] = useState(100) - const [expandedRows, setExpandedRows] = useState([]) - const [members, setMembers] = useState(loadedMembers) - const [viewFormat, setViewFormat] = useState(viewFormatProp) - - const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) - - const dispatch = useDispatch() - - useEffect(() => { - setMembers(loadedMembers) - - if (loadedMembers.length < members.length) { - formattedLastIndexRef.current = 0 - } - - if (viewFormat !== viewFormatProp) { - setExpandedRows([]) - setViewFormat(viewFormatProp) - - cellCache.clearAll() - setTimeout(() => { - cellCache.clearAll() - }, 0) - } - }, [loadedMembers, viewFormatProp]) - - const closePopover = () => { - setDeleting('') - } - - const showPopover = (member = '') => { - setDeleting(`${member + suffix}`) - } - - const onSuccessRemoved = (newTotal: number) => { - newTotal === 0 && onRemoveKey() - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.Set, - numberOfRemoved: 1, - } - }) - } - - const handleDeleteMember = (member: string | RedisString = '') => { - dispatch(deleteSetMembers(key, [member], onSuccessRemoved)) - closePopover() - } + const keyType = KeyTypes.Set + const { onRemoveKey, onOpenAddItemPanel, onCloseAddItemPanel } = props - const handleRemoveIconClick = () => { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.Set - } - }) - } + const { loading } = useSelector(selectedKeySelector) - const handleSearch = (search: IColumnSearchState[]) => { - const fieldColumn = search.find((column) => column.id === 'name') - if (!fieldColumn) { return } + const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) - const { value: match } = fieldColumn - const onSuccess = (data: GetSetMembersResponse) => { - const matchValue = getMatchType(match) - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.Set, - match: matchValue, - length: data.total, - } - }) - } - setMatch(match) - dispatch(fetchSetMembers(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, true, onSuccess)) + const openAddItemPanel = () => { + setIsAddItemPanelOpen(true) + onOpenAddItemPanel() } - const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { - const browserViewEvent = expanded - ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED - const treeViewEvent = expanded - ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED - - sendEventTelemetry({ - event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), - eventData: { - keyType: KeyTypes.Set, - databaseId: instanceId, - largestCellLength: members[rowIndex]?.length || 0, - } - }) - - cellCache.clearAll() - } - - const columns:ITableColumn[] = [ - { - id: 'name', - label: 'Member', - isSearchable: true, - staySearchAlwaysOpen: true, - initialSearchValue: '', - truncateText: true, - render: function Name(_name: string, memberItem: RedisResponseBuffer, expanded: boolean = false) { - const { value: decompressedMemberItem } = decompressingBuffer(memberItem, compressor) - const member = bufferToString(memberItem) - // Better to cut the long string, because it could affect virtual scroll performance - const tooltipContent = formatLongName(member) - const { value, isValid } = formattingBuffer(decompressedMemberItem, viewFormatProp, { expanded }) - const cellContent = value?.substring?.(0, 200) ?? value - - return ( - -
- {!expanded && ( - - <>{cellContent} - - )} - {expanded && value} -
-
- ) - }, - }, - { - id: 'actions', - label: '', - relativeWidth: 60, - minWidth: 60, - maxWidth: 60, - headerClassName: 'hidden', - render: function Actions(_act: any, memberItem: RedisResponseBuffer) { - const member = bufferToString(memberItem, viewFormat) - return ( -
- -
- ) - }, - }, - ] - - const loadMoreItems = () => { - if (nextCursor !== 0) { - dispatch( - fetchMoreSetMembers(key, nextCursor, SCAN_COUNT_DEFAULT, match || matchAllValue) - ) - } + const closeAddItemPanel = () => { + setIsAddItemPanelOpen(false) + onCloseAddItemPanel() } return ( -
- {loading && ( - - )} - - ({ - ...column, - width: getColumnWidth(i, width, arr) - }))} - onChangeWidth={setWidth} - cellCache={cellCache} - onRowToggleViewClick={handleRowToggleViewClick} - expandedRows={expandedRows} - setExpandedRows={setExpandedRows} +
+ - +
+ {!loading && ( +
+ +
+ )} + {isAddItemPanelOpen && ( + + )} +
+ ) } -export default SetDetails +export { SetDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/index.ts new file mode 100644 index 0000000000..b2d160c4a7 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/index.ts @@ -0,0 +1 @@ +export { SetDetails } from './SetDetails' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.spec.tsx new file mode 100644 index 0000000000..e49bc85818 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { setDataSelector } from 'uiSrc/slices/browser/set' +import { anyToBuffer } from 'uiSrc/utils' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { GZIP_COMPRESSED_VALUE_1, DECOMPRESSED_VALUE_STR_1 } from 'uiSrc/utils/tests/decompressors' +import { SetDetailsTable, Props } from './SetDetailsTable' + +const members = [ + { type: 'Buffer', data: [49] }, + { type: 'Buffer', data: [50] }, + { type: 'Buffer', data: [51] }, +] +const mockedProps = mock() + +jest.mock('uiSrc/slices/browser/set', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/set').initialState + return ({ + setSelector: jest.fn().mockReturnValue(defaultState), + setDataSelector: jest.fn().mockReturnValue({ + ...defaultState, + total: 3, + key: { type: 'Buffer', data: [49] }, + keyName: { type: 'Buffer', data: [49] }, + members, + }), + fetchSetMembers: () => jest.fn() + }) +}) + +describe('SetDetailsTable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render rows properly', () => { + const { container } = render() + const rows = container.querySelectorAll('.ReactVirtualized__Table__row[role="row"]') + expect(rows).toHaveLength(members.length) + }) + + it('should render search input', () => { + render() + expect(screen.getByTestId('search')).toBeTruthy() + }) + + it('should call search', () => { + render() + const searchInput = screen.getByTestId('search') + fireEvent.change( + searchInput, + { target: { value: '*1*' } } + ) + expect(searchInput).toHaveValue('*1*') + }) + + it('should render delete popup after click remove button', () => { + render() + fireEvent.click(screen.getAllByTestId(/set-remove-btn/)[0]) + expect(screen.getByTestId(/set-remove-btn-1-icon/)).toBeInTheDocument() + }) + + describe('decompressed data', () => { + it('should render decompressed GZIP data = "1"', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/set').initialState + const setDataSelectorMock = jest.fn().mockReturnValue({ + ...defaultState, + key: '123zxczxczxc', + members: [ + anyToBuffer(GZIP_COMPRESSED_VALUE_1), + ] + }) + setDataSelector.mockImplementation(setDataSelectorMock) + + const { queryByTestId } = render() + const memberEl = queryByTestId(/set-member-value-/) + + expect(memberEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.tsx new file mode 100644 index 0000000000..e4aebab83a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/SetDetailsTable.tsx @@ -0,0 +1,314 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { + EuiProgress, + EuiText, + EuiToolTip, +} from '@elastic/eui' +import { CellMeasurerCache } from 'react-virtualized' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' + +import { + bufferToString, + createDeleteFieldHeader, + createDeleteFieldMessage, + formatLongName, + formattingBuffer, +} from 'uiSrc/utils' +import { KeyTypes, OVER_RENDER_BUFFER_COUNT, TEXT_FAILED_CONVENT_FORMATTER } from 'uiSrc/constants' +import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { + deleteSetMembers, + fetchSetMembers, + fetchMoreSetMembers, + setDataSelector, + setSelector, +} from 'uiSrc/slices/browser/set' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import HelpTexts from 'uiSrc/constants/help-texts' +import { NoResultsFoundText } from 'uiSrc/constants/texts' +import VirtualTable from 'uiSrc/components/virtual-table' +import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { getColumnWidth } from 'uiSrc/components/virtual-grid' +import { IColumnSearchState, ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' +import { GetSetMembersResponse } from 'apiSrc/modules/browser/dto/set.dto' +import styles from './styles.module.scss' + +const suffix = '_set' +const headerHeight = 60 +const rowHeight = 43 +const footerHeight = 0 +const matchAllValue = '*' + +const cellCache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: rowHeight, +}) + +export interface Props { + isFooterOpen: boolean + onRemoveKey: () => void +} + +const SetDetailsTable = (props: Props) => { + const { isFooterOpen, onRemoveKey } = props + + const { loading } = useSelector(setSelector) + const { members: loadedMembers, total, nextCursor } = useSelector(setDataSelector) + const { length = 0, name: key } = useSelector(selectedKeyDataSelector) ?? {} + const { id: instanceId, compressor = null } = useSelector(connectedInstanceSelector) + const { viewType } = useSelector(keysSelector) + const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + + const [match, setMatch] = useState('*') + const [deleting, setDeleting] = useState('') + const [width, setWidth] = useState(100) + const [expandedRows, setExpandedRows] = useState([]) + const [members, setMembers] = useState(loadedMembers) + const [viewFormat, setViewFormat] = useState(viewFormatProp) + + const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) + + const dispatch = useDispatch() + + useEffect(() => { + setMembers(loadedMembers) + + if (loadedMembers.length < members.length) { + formattedLastIndexRef.current = 0 + } + + if (viewFormat !== viewFormatProp) { + setExpandedRows([]) + setViewFormat(viewFormatProp) + + cellCache.clearAll() + setTimeout(() => { + cellCache.clearAll() + }, 0) + } + }, [loadedMembers, viewFormatProp]) + + const closePopover = () => { + setDeleting('') + } + + const showPopover = (member = '') => { + setDeleting(`${member + suffix}`) + } + + const onSuccessRemoved = (newTotal: number) => { + newTotal === 0 && onRemoveKey() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Set, + numberOfRemoved: 1, + } + }) + } + + const handleDeleteMember = (member: string | RedisString = '') => { + dispatch(deleteSetMembers(key, [member], onSuccessRemoved)) + closePopover() + } + + const handleRemoveIconClick = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Set + } + }) + } + + const handleSearch = (search: IColumnSearchState[]) => { + const fieldColumn = search.find((column) => column.id === 'name') + if (!fieldColumn) { return } + + const { value: match } = fieldColumn + const onSuccess = (data: GetSetMembersResponse) => { + const matchValue = getMatchType(match) + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Set, + match: matchValue, + length: data.total, + } + }) + } + setMatch(match) + dispatch(fetchSetMembers(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, true, onSuccess)) + } + + const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { + const browserViewEvent = expanded + ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED + const treeViewEvent = expanded + ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED + + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + keyType: KeyTypes.Set, + databaseId: instanceId, + largestCellLength: members[rowIndex]?.length || 0, + } + }) + + cellCache.clearAll() + } + + const columns:ITableColumn[] = [ + { + id: 'name', + label: 'Member', + isSearchable: true, + staySearchAlwaysOpen: true, + initialSearchValue: '', + truncateText: true, + render: function Name(_name: string, memberItem: RedisResponseBuffer, expanded: boolean = false) { + const { value: decompressedMemberItem } = decompressingBuffer(memberItem, compressor) + const member = bufferToString(memberItem) + // Better to cut the long string, because it could affect virtual scroll performance + const tooltipContent = formatLongName(member) + const { value, isValid } = formattingBuffer(decompressedMemberItem, viewFormatProp, { expanded }) + const cellContent = value?.substring?.(0, 200) ?? value + + return ( + +
+ {!expanded && ( + + <>{cellContent} + + )} + {expanded && value} +
+
+ ) + }, + }, + { + id: 'actions', + label: '', + relativeWidth: 60, + minWidth: 60, + maxWidth: 60, + headerClassName: 'hidden', + render: function Actions(_act: any, memberItem: RedisResponseBuffer) { + const member = bufferToString(memberItem, viewFormat) + return ( +
+ +
+ ) + }, + }, + ] + + const loadMoreItems = () => { + if (nextCursor !== 0) { + dispatch( + fetchMoreSetMembers(key, nextCursor, SCAN_COUNT_DEFAULT, match || matchAllValue) + ) + } + } + + return ( +
+ {loading && ( + + )} + + ({ + ...column, + width: getColumnWidth(i, width, arr) + }))} + onChangeWidth={setWidth} + cellCache={cellCache} + onRowToggleViewClick={handleRowToggleViewClick} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + /> + +
+ ) +} + +export { SetDetailsTable } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/index.ts new file mode 100644 index 0000000000..8c6311a3f3 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/index.ts @@ -0,0 +1 @@ +export { SetDetailsTable } from './SetDetailsTable' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/set-details-table/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx new file mode 100644 index 0000000000..4408fd19d1 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, StreamDetails } from './StreamDetails' + +const mockedProps = mock() + +describe('StreamDetails', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx new file mode 100644 index 0000000000..69d5d049ae --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react' +import { useSelector } from 'react-redux' + +import { + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' + +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' +import { streamSelector } from 'uiSrc/slices/browser/stream' +import { StreamDetailsBody } from './stream-details-body' + +import { AddItemsPanel } from '../add-items-panel' + +export interface Props extends KeyDetailsHeaderProps { + onRemoveKey: () => void + onOpenAddItemPanel: () => void + onCloseAddItemPanel: () => void +} + +const StreamDetails = (props: Props) => { + const keyType = KeyTypes.Stream + const { onOpenAddItemPanel, onCloseAddItemPanel } = props + + const { loading } = useSelector(selectedKeySelector) + const { viewType: streamViewType } = useSelector(streamSelector) + + const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) + + const openAddItemPanel = () => { + setIsAddItemPanelOpen(true) + + if (!STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { + onOpenAddItemPanel() + } + } + + const closeAddItemPanel = (isCancelled?: boolean) => { + setIsAddItemPanelOpen(false) + if (isCancelled && isAddItemPanelOpen && !STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { + onCloseAddItemPanel() + } + } + + return ( +
+ +
+ {!loading && ( +
+ +
+ )} + {isAddItemPanelOpen && ( + + )} +
+
+ ) +} + +export { StreamDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/index.ts index b1726a2381..3ec08e508c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/index.ts @@ -1,3 +1 @@ -import StreamDetailsWrapper from './StreamDetailsWrapper' - -export default StreamDetailsWrapper +export { StreamDetails } from './StreamDetails' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/StreamDetailsBody.spec.tsx similarity index 86% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/StreamDetailsBody.spec.tsx index 58b679ee43..263cd499b6 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/StreamDetailsBody.spec.tsx @@ -4,8 +4,8 @@ import { streamDataSelector, streamRangeSelector } from 'uiSrc/slices/browser/st import { anyToBuffer, bufferToString, stringToBuffer } from 'uiSrc/utils' import { render, screen } from 'uiSrc/utils/test-utils' import { GZIP_COMPRESSED_VALUE_1, GZIP_COMPRESSED_VALUE_2, DECOMPRESSED_VALUE_STR_1, DECOMPRESSED_VALUE_STR_2 } from 'uiSrc/utils/tests/decompressors' -import { MAX_FORMAT_LENGTH_STREAM_TIMESTAMP } from './constants' -import StreamDetailsWrapper, { Props } from './StreamDetailsWrapper' +import { StreamDetailsBody, Props } from './StreamDetailsBody' +import { MAX_FORMAT_LENGTH_STREAM_TIMESTAMP } from '../constants' jest.mock('uiSrc/slices/browser/stream', () => ({ ...jest.requireActual('uiSrc/slices/browser/stream'), @@ -59,13 +59,13 @@ const mockedRangeData = { end: '1675751507406', } -describe('StreamDetailsWrapper', () => { +describe('StreamDetailsBody', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('should render Stream Data container', () => { - render() + render() expect(screen.getByTestId('stream-entries-container')).toBeInTheDocument() }) @@ -79,7 +79,7 @@ describe('StreamDetailsWrapper', () => { ...mockedRangeData, })) - render() + render() expect(screen.getByTestId('range-bar')).toBeInTheDocument() }) @@ -105,7 +105,7 @@ describe('StreamDetailsWrapper', () => { ...mockedRangeData, })) - const { queryByTestId } = render() + const { queryByTestId } = render() expect(queryByTestId('range-bar')).not.toBeInTheDocument() }) @@ -130,7 +130,7 @@ describe('StreamDetailsWrapper', () => { ], })) - const { queryAllByTestId } = render() + const { queryAllByTestId } = render() const fieldNameEl = queryAllByTestId(/stream-field-name-/)?.[0] const entryFieldEl = queryAllByTestId(/stream-entry-field-/)?.[0] diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/StreamDetailsBody.tsx similarity index 94% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/StreamDetailsBody.tsx index 2674607b00..d9646806d7 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/StreamDetailsBody.tsx @@ -25,12 +25,12 @@ import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import RangeFilter from 'uiSrc/components/range-filter' import { GetStreamEntriesResponse } from 'apiSrc/modules/browser/dto/stream.dto' -import ConsumersViewWrapper from './consumers-view' -import GroupsViewWrapper from './groups-view' -import MessagesViewWrapper from './messages-view' -import StreamDataViewWrapper from './stream-data-view' -import StreamTabs from './stream-tabs' -import { MAX_FORMAT_LENGTH_STREAM_TIMESTAMP } from './constants' +import ConsumersViewWrapper from '../consumers-view' +import GroupsViewWrapper from '../groups-view' +import MessagesViewWrapper from '../messages-view' +import StreamDataViewWrapper from '../stream-data-view' +import StreamTabs from '../stream-tabs' +import { MAX_FORMAT_LENGTH_STREAM_TIMESTAMP } from '../constants' import styles from './styles.module.scss' @@ -38,7 +38,7 @@ export interface Props { isFooterOpen: boolean } -const StreamDetailsWrapper = (props: Props) => { +const StreamDetailsBody = (props: Props) => { const { viewType, loading, sortOrder: entryColumnSortOrder } = useSelector(streamSelector) const { loading: loadingGroups } = useSelector(streamGroupsSelector) const { start, end } = useSelector(streamRangeSelector) @@ -231,4 +231,4 @@ const StreamDetailsWrapper = (props: Props) => { ) } -export default StreamDetailsWrapper +export { StreamDetailsBody } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/index.ts new file mode 100644 index 0000000000..08998551aa --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/index.ts @@ -0,0 +1 @@ +export { StreamDetailsBody } from './StreamDetailsBody' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/stream-details-body/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx index 0553462cdd..8e61a909d5 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx @@ -1,289 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { KeyValueCompressor } from 'uiSrc/constants' -import { - fetchDownloadStringValue, - stringDataSelector -} from 'uiSrc/slices/browser/string' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { anyToBuffer, bufferToString } from 'uiSrc/utils' -import { render, screen, fireEvent, act } from 'uiSrc/utils/test-utils' -import { - GZIP_COMPRESSED_VALUE_1, - GZIP_COMPRESSED_VALUE_2, - DECOMPRESSED_VALUE_STR_1, - DECOMPRESSED_VALUE_STR_2, -} from 'uiSrc/utils/tests/decompressors' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { downloadFile } from 'uiSrc/utils/dom/downloadFile' -import StringDetails, { Props } from './StringDetails' - -const STRING_VALUE = 'string-value' -const STRING_VALUE_SPACE = 'string value' -const LOAD_ALL_BTN = 'load-all-value-btn' -const DOWNLOAD_BTN = 'download-all-value-btn' - -const STRING_MAX_LENGTH = 2 -const STRING_LENGTH = 4 - -const fullValue = { type: 'Buffer', data: [49, 50, 51, 52], } -const partValue = { type: 'Buffer', data: [49, 50], } +import { render } from 'uiSrc/utils/test-utils' +import { Props, StringDetails } from './StringDetails' const mockedProps = mock() -jest.mock('uiSrc/slices/browser/string', () => ({ - ...jest.requireActual('uiSrc/slices/browser/string'), - stringDataSelector: jest.fn().mockReturnValue({ - value: fullValue - }), - fetchDownloadStringValue: jest.fn(), -})) - -jest.mock('uiSrc/slices/browser/keys', () => ({ - ...jest.requireActual('uiSrc/slices/browser/keys'), - selectedKeyDataSelector: jest.fn().mockReturnValue({ - name: fullValue, - type: 'string', - length: STRING_LENGTH - }), -})) - -jest.mock('uiSrc/constants', () => ({ - ...jest.requireActual('uiSrc/constants'), - STRING_MAX_LENGTH, -})) - -jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - connectedInstanceSelector: jest.fn().mockReturnValue({ - compressor: null, - }), -})) - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: () => jest.fn().mockReturnValue(() => jest.fn()), -})) - describe('StringDetails', () => { it('should render', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should render textarea if edit mode', () => { - render( - - ) - const textArea = screen.getByTestId(STRING_VALUE) - expect(textArea).toBeInTheDocument() - }) - - it('should update string value', () => { - render( - - ) - const textArea = screen.getByTestId(STRING_VALUE) - fireEvent.change( - textArea, - { target: { value: STRING_VALUE_SPACE } } - ) - expect(textArea).toHaveValue(STRING_VALUE_SPACE) - }) - - it('should stay empty string after cancel', async () => { - render( - - ) - const textArea = screen.getByTestId(STRING_VALUE) - fireEvent.change( - textArea, - { target: { value: STRING_VALUE_SPACE } } - ) - const btnACancel = screen.getByTestId('cancel-btn') - await act(() => { - fireEvent.click(btnACancel) - }) - const textArea2 = screen.getByTestId(STRING_VALUE) - expect(textArea2).toHaveValue(bufferToString(fullValue)) - }) - - it('should update value after apply', () => { - render( - - ) - const textArea = screen.getByTestId(STRING_VALUE) - fireEvent.change( - textArea, - { target: { value: STRING_VALUE_SPACE } } - ) - const btnApply = screen.getByTestId('apply-btn') - fireEvent.click(btnApply) - expect(textArea).toHaveValue(STRING_VALUE_SPACE) - }) - - it('should render load button and download button if long string is partially loaded', () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: partValue - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - render( - - ) - const loadAllBtn = screen.getByTestId(LOAD_ALL_BTN) - const downloadBtn = screen.getByTestId(DOWNLOAD_BTN) - expect(loadAllBtn).toBeInTheDocument() - expect(downloadBtn).toBeInTheDocument() - }) - - it('should call onRefresh and sendEventTelemetry after clicking on load button', () => { - const onRefresh = jest.fn() - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: partValue - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - render( - - ) - - fireEvent.click(screen.getByTestId(LOAD_ALL_BTN)) - - expect(onRefresh).toBeCalled() - expect(onRefresh).toBeCalledWith(fullValue, 'string', { end: STRING_MAX_LENGTH + 1 }) - expect(sendEventTelemetry).toBeCalled() - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.STRING_LOAD_ALL_CLICKED, - eventData: { databaseId: undefined, length: STRING_LENGTH } - }) - }) - - it('Should add "..." in the end of the part value', async () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: partValue - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - render( - - ) - expect(screen.getByTestId(STRING_VALUE)).toHaveTextContent(`${bufferToString(partValue)}...`) - }) - - it('Should not add "..." in the end of the full value', async () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: fullValue - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - render( - - ) - expect(screen.getByTestId(STRING_VALUE)).toHaveTextContent(bufferToString(fullValue)) - }) - - it('should call fetchDownloadStringValue and sendEventTelemetry after clicking on load button and download button', async () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: partValue - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - render( - - ) - - fireEvent.click(screen.getByTestId(DOWNLOAD_BTN)) - - expect(sendEventTelemetry).toBeCalled() - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.STRING_DOWNLOAD_VALUE_CLICKED, - eventData: { databaseId: undefined, length: STRING_LENGTH } - }) - expect(fetchDownloadStringValue).toBeCalled() - expect(fetchDownloadStringValue).toBeCalledWith(fullValue, downloadFile) - }) - - describe('decompressed data', () => { - it('should render decompressed GZIP data = "1"', () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: anyToBuffer(GZIP_COMPRESSED_VALUE_1) - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - connectedInstanceSelector.mockImplementation(() => ({ - compressor: KeyValueCompressor.GZIP, - })) - - render( - - ) - const textArea = screen.getByTestId(STRING_VALUE) - - expect(textArea).toHaveValue(DECOMPRESSED_VALUE_STR_1) - }) - - it('should render decompressed GZIP data = "2"', () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: anyToBuffer(GZIP_COMPRESSED_VALUE_2) - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - connectedInstanceSelector.mockImplementation(() => ({ - compressor: KeyValueCompressor.GZIP, - })) - - render( - - ) - const textArea = screen.getByTestId(STRING_VALUE) - - expect(textArea).toHaveValue(DECOMPRESSED_VALUE_STR_2) - }) + expect(render()).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx index 387056290c..dbc5ee9f1f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx @@ -1,303 +1,52 @@ -import React, { - ChangeEvent, - Ref, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiProgress, EuiText, EuiTextArea, EuiToolTip } from '@elastic/eui' import { - bufferToSerializedFormat, - bufferToString, - formattingBuffer, - isNonUnicodeFormatter, - isEqualBuffers, - isFormatEditable, - stringToBuffer, - stringToSerializedBufferFormat, - isFullStringLoaded, -} from 'uiSrc/utils' -import { - fetchDownloadStringValue, - resetStringValue, - setIsStringCompressed, - stringDataSelector, - stringSelector, - updateStringValueAction, -} from 'uiSrc/slices/browser/string' -import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' -import { AddStringFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' -import { selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { - KeyTypes, ModulesKeyTypes, - TEXT_DISABLED_COMPRESSED_VALUE, - TEXT_FAILED_CONVENT_FORMATTER, - TEXT_INVALID_VALUE, - TEXT_UNPRINTABLE_CHARACTERS, - STRING_MAX_LENGTH -} from 'uiSrc/constants' -import { calculateTextareaLines } from 'uiSrc/utils/calculateTextareaLines' -import { decompressingBuffer } from 'uiSrc/utils/decompressors' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' + refreshKey, + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' + +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { downloadFile } from 'uiSrc/utils/dom/downloadFile' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' +import { StringDetailsTable } from './string-details-table' -import styles from './styles.module.scss' - -const MAX_ROWS = 25 -const MIN_ROWS = 4 -const APPROXIMATE_WIDTH_OF_SIGN = 8.6 -const MAX_LENGTH = STRING_MAX_LENGTH + 1 - -export interface Props { - isEditItem: boolean; - setIsEdit: (isEdit: boolean) => void; - onRefresh: (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args: IFetchKeyArgs) => void; -} +export interface Props extends KeyDetailsHeaderProps {} const StringDetails = (props: Props) => { - const { isEditItem, setIsEdit, onRefresh } = props - - const { compressor = null } = useSelector(connectedInstanceSelector) - const { loading } = useSelector(stringSelector) - const { id: instanceId } = useSelector(connectedInstanceSelector) - const { value: initialValue } = useSelector(stringDataSelector) - const { name: key, type: keyType, length } = useSelector(selectedKeyDataSelector) ?? { name: '' } - const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + const keyType = KeyTypes.String - const [rows, setRows] = useState(5) - const [value, setValue] = useState('') - const [areaValue, setAreaValue] = useState('') - const [viewFormat, setViewFormat] = useState(viewFormatProp) - const [isValid, setIsValid] = useState(true) - const [isDisabled, setIsDisabled] = useState(false) - const [isEditable, setIsEditable] = useState(true) - const [noEditableText, setNoEditableText] = useState(TEXT_DISABLED_COMPRESSED_VALUE) - - const textAreaRef: Ref = useRef(null) - const viewValueRef: Ref = useRef(null) + const { loading } = useSelector(selectedKeySelector) + const [editItem, setEditItem] = useState(false) const dispatch = useDispatch() - useEffect(() => () => { - dispatch(resetStringValue()) - }, []) - - useEffect(() => { - if (!initialValue) return - - const { value: decompressedValue, isCompressed } = decompressingBuffer(initialValue, compressor) - - const initialValueString = bufferToString(decompressedValue, viewFormat) - const { value: formattedValue, isValid } = formattingBuffer(decompressedValue, viewFormatProp, { expanded: true }) - setAreaValue(initialValueString) - - setValue(!isFullStringLoaded(initialValue?.data?.length, length) ? `${formattedValue}...` : formattedValue) - setIsValid(isValid) - setIsDisabled( - !isNonUnicodeFormatter(viewFormatProp, isValid) - && !isEqualBuffers(initialValue, stringToBuffer(initialValueString)) - ) - setIsEditable( - !isCompressed - && isFormatEditable(viewFormatProp) - && isFullStringLoaded(initialValue?.data?.length, length) - ) - setNoEditableText(isCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_FAILED_CONVENT_FORMATTER(viewFormatProp)) - - dispatch(setIsStringCompressed(isCompressed)) - - if (viewFormat !== viewFormatProp) { - setViewFormat(viewFormatProp) - } - }, [initialValue, viewFormatProp, compressor, length]) - - useEffect(() => { - // Approximate calculation of textarea rows by initialValue - if (!isEditItem || !textAreaRef.current || value === null) { - return - } - const calculatedRows = calculateTextareaLines(areaValue, textAreaRef.current.clientWidth, APPROXIMATE_WIDTH_OF_SIGN) - - if (calculatedRows > MAX_ROWS) { - setRows(MAX_ROWS) - return - } - if (calculatedRows < MIN_ROWS) { - setRows(MIN_ROWS) - return - } - setRows(calculatedRows) - }, [viewValueRef, isEditItem]) - - useMemo(() => { - if (isEditItem && initialValue) { - (document.activeElement as HTMLElement)?.blur() - setAreaValue(bufferToSerializedFormat(viewFormat, initialValue, 4)) - } - }, [isEditItem]) - - const onApplyChanges = () => { - const data = stringToSerializedBufferFormat(viewFormat, areaValue) - const onSuccess = () => { - setIsEdit(false) - setValue(formattingBuffer(data, viewFormat, { expanded: true })?.value) - } - dispatch(updateStringValueAction(key, data, onSuccess)) - } - - const onDeclineChanges = useCallback(() => { - if (!initialValue) return - - setAreaValue(bufferToSerializedFormat(viewFormat, initialValue, 4)) - setIsEdit(false) - }, [initialValue]) - - const isLoading = loading || value === null - - const handleLoadAll = (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes) => { - const endString = length - 1 - onRefresh(key, type, { end: endString }) - sendEventTelemetry({ - event: TelemetryEvent.STRING_LOAD_ALL_CLICKED, - eventData: { - databaseId: instanceId, - length, - } - }) - } - - const handleDownloadString = (e: React.MouseEvent) => { - e.preventDefault() - dispatch(fetchDownloadStringValue(key, downloadFile)) - sendEventTelemetry({ - event: TelemetryEvent.STRING_DOWNLOAD_VALUE_CLICKED, - eventData: { - databaseId: instanceId, - length, - } - }) + const handleRefreshKey = (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args: IFetchKeyArgs) => { + dispatch(refreshKey(key, type, args)) } return ( - <> -
- {isLoading && ( - - )} - {!isEditItem && ( - isEditable && setIsEdit(true)} - style={{ whiteSpace: 'break-spaces' }} - data-testid="string-value" - > - {areaValue !== '' - ? (isValid - ? value - : ( - - <>{value} - - ) - ) - : (!isLoading && (Empty))} - - )} - {isEditItem && ( - - formattingBuffer( - stringToSerializedBufferFormat(viewFormat, areaValue), - viewFormat - )?.isValid} - > - ) => { - setAreaValue(e.target.value) - }} - disabled={loading} - inputRef={textAreaRef} - className={cx(styles.stringTextArea, { [styles.areaWarning]: isDisabled })} - data-testid="string-value" +
+ setEditItem(!editItem)} + /> +
+ {!loading && ( +
+ setEditItem(isEdit)} + onRefresh={handleRefreshKey} /> - +
)}
- - {length > MAX_LENGTH && ( -
- - - {!isFullStringLoaded(initialValue?.data?.length, length) && ( - handleLoadAll(key, keyType)} - > - Load all - - )} - - - - Download - - - -
- )} - +
) } -export default StringDetails +export { StringDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/index.ts new file mode 100644 index 0000000000..85694dd7fa --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/index.ts @@ -0,0 +1 @@ +export { StringDetails } from './StringDetails' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.spec.tsx new file mode 100644 index 0000000000..8771a36ad6 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.spec.tsx @@ -0,0 +1,289 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { KeyValueCompressor } from 'uiSrc/constants' +import { + fetchDownloadStringValue, + stringDataSelector +} from 'uiSrc/slices/browser/string' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { anyToBuffer, bufferToString } from 'uiSrc/utils' +import { render, screen, fireEvent, act } from 'uiSrc/utils/test-utils' +import { + GZIP_COMPRESSED_VALUE_1, + GZIP_COMPRESSED_VALUE_2, + DECOMPRESSED_VALUE_STR_1, + DECOMPRESSED_VALUE_STR_2, +} from 'uiSrc/utils/tests/decompressors' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { downloadFile } from 'uiSrc/utils/dom/downloadFile' +import { StringDetailsTable, Props } from './StringDetailsTable' + +const STRING_VALUE = 'string-value' +const STRING_VALUE_SPACE = 'string value' +const LOAD_ALL_BTN = 'load-all-value-btn' +const DOWNLOAD_BTN = 'download-all-value-btn' + +const STRING_MAX_LENGTH = 2 +const STRING_LENGTH = 4 + +const fullValue = { type: 'Buffer', data: [49, 50, 51, 52], } +const partValue = { type: 'Buffer', data: [49, 50], } + +const mockedProps = mock() + +jest.mock('uiSrc/slices/browser/string', () => ({ + ...jest.requireActual('uiSrc/slices/browser/string'), + stringDataSelector: jest.fn().mockReturnValue({ + value: fullValue + }), + fetchDownloadStringValue: jest.fn(), +})) + +jest.mock('uiSrc/slices/browser/keys', () => ({ + ...jest.requireActual('uiSrc/slices/browser/keys'), + selectedKeyDataSelector: jest.fn().mockReturnValue({ + name: fullValue, + type: 'string', + length: STRING_LENGTH + }), +})) + +jest.mock('uiSrc/constants', () => ({ + ...jest.requireActual('uiSrc/constants'), + STRING_MAX_LENGTH, +})) + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + compressor: null, + }), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => jest.fn().mockReturnValue(() => jest.fn()), +})) + +describe('StringDetailsTable', () => { + it('should render', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should render textarea if edit mode', () => { + render( + + ) + const textArea = screen.getByTestId(STRING_VALUE) + expect(textArea).toBeInTheDocument() + }) + + it('should update string value', () => { + render( + + ) + const textArea = screen.getByTestId(STRING_VALUE) + fireEvent.change( + textArea, + { target: { value: STRING_VALUE_SPACE } } + ) + expect(textArea).toHaveValue(STRING_VALUE_SPACE) + }) + + it('should stay empty string after cancel', async () => { + render( + + ) + const textArea = screen.getByTestId(STRING_VALUE) + fireEvent.change( + textArea, + { target: { value: STRING_VALUE_SPACE } } + ) + const btnACancel = screen.getByTestId('cancel-btn') + await act(() => { + fireEvent.click(btnACancel) + }) + const textArea2 = screen.getByTestId(STRING_VALUE) + expect(textArea2).toHaveValue(bufferToString(fullValue)) + }) + + it('should update value after apply', () => { + render( + + ) + const textArea = screen.getByTestId(STRING_VALUE) + fireEvent.change( + textArea, + { target: { value: STRING_VALUE_SPACE } } + ) + const btnApply = screen.getByTestId('apply-btn') + fireEvent.click(btnApply) + expect(textArea).toHaveValue(STRING_VALUE_SPACE) + }) + + it('should render load button and download button if long string is partially loaded', () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: partValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + const loadAllBtn = screen.getByTestId(LOAD_ALL_BTN) + const downloadBtn = screen.getByTestId(DOWNLOAD_BTN) + expect(loadAllBtn).toBeInTheDocument() + expect(downloadBtn).toBeInTheDocument() + }) + + it('should call onRefresh and sendEventTelemetry after clicking on load button', () => { + const onRefresh = jest.fn() + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: partValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + + fireEvent.click(screen.getByTestId(LOAD_ALL_BTN)) + + expect(onRefresh).toBeCalled() + expect(onRefresh).toBeCalledWith(fullValue, 'string', { end: STRING_MAX_LENGTH + 1 }) + expect(sendEventTelemetry).toBeCalled() + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.STRING_LOAD_ALL_CLICKED, + eventData: { databaseId: undefined, length: STRING_LENGTH } + }) + }) + + it('Should add "..." in the end of the part value', async () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: partValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + expect(screen.getByTestId(STRING_VALUE)).toHaveTextContent(`${bufferToString(partValue)}...`) + }) + + it('Should not add "..." in the end of the full value', async () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: fullValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + expect(screen.getByTestId(STRING_VALUE)).toHaveTextContent(bufferToString(fullValue)) + }) + + it('should call fetchDownloadStringValue and sendEventTelemetry after clicking on load button and download button', async () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: partValue + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + + fireEvent.click(screen.getByTestId(DOWNLOAD_BTN)) + + expect(sendEventTelemetry).toBeCalled() + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.STRING_DOWNLOAD_VALUE_CLICKED, + eventData: { databaseId: undefined, length: STRING_LENGTH } + }) + expect(fetchDownloadStringValue).toBeCalled() + expect(fetchDownloadStringValue).toBeCalledWith(fullValue, downloadFile) + }) + + describe('decompressed data', () => { + it('should render decompressed GZIP data = "1"', () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: anyToBuffer(GZIP_COMPRESSED_VALUE_1) + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + connectedInstanceSelector.mockImplementation(() => ({ + compressor: KeyValueCompressor.GZIP, + })) + + render( + + ) + const textArea = screen.getByTestId(STRING_VALUE) + + expect(textArea).toHaveValue(DECOMPRESSED_VALUE_STR_1) + }) + + it('should render decompressed GZIP data = "2"', () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: anyToBuffer(GZIP_COMPRESSED_VALUE_2) + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + connectedInstanceSelector.mockImplementation(() => ({ + compressor: KeyValueCompressor.GZIP, + })) + + render( + + ) + const textArea = screen.getByTestId(STRING_VALUE) + + expect(textArea).toHaveValue(DECOMPRESSED_VALUE_STR_2) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx new file mode 100644 index 0000000000..cd92f6115b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx @@ -0,0 +1,303 @@ +import React, { + ChangeEvent, + Ref, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiProgress, EuiText, EuiTextArea, EuiToolTip } from '@elastic/eui' + +import { + bufferToSerializedFormat, + bufferToString, + formattingBuffer, + isNonUnicodeFormatter, + isEqualBuffers, + isFormatEditable, + stringToBuffer, + stringToSerializedBufferFormat, + isFullStringLoaded, +} from 'uiSrc/utils' +import { + fetchDownloadStringValue, + resetStringValue, + setIsStringCompressed, + stringDataSelector, + stringSelector, + updateStringValueAction, +} from 'uiSrc/slices/browser/string' +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { AddStringFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { + KeyTypes, ModulesKeyTypes, + TEXT_DISABLED_COMPRESSED_VALUE, + TEXT_FAILED_CONVENT_FORMATTER, + TEXT_INVALID_VALUE, + TEXT_UNPRINTABLE_CHARACTERS, + STRING_MAX_LENGTH +} from 'uiSrc/constants' +import { calculateTextareaLines } from 'uiSrc/utils/calculateTextareaLines' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { downloadFile } from 'uiSrc/utils/dom/downloadFile' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' + +import styles from './styles.module.scss' + +const MAX_ROWS = 25 +const MIN_ROWS = 4 +const APPROXIMATE_WIDTH_OF_SIGN = 8.6 +const MAX_LENGTH = STRING_MAX_LENGTH + 1 + +export interface Props { + isEditItem: boolean; + setIsEdit: (isEdit: boolean) => void; + onRefresh: (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes, args: IFetchKeyArgs) => void; +} + +const StringDetailsTable = (props: Props) => { + const { isEditItem, setIsEdit, onRefresh } = props + + const { compressor = null } = useSelector(connectedInstanceSelector) + const { loading } = useSelector(stringSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { value: initialValue } = useSelector(stringDataSelector) + const { name: key, type: keyType, length } = useSelector(selectedKeyDataSelector) ?? { name: '' } + const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + + const [rows, setRows] = useState(5) + const [value, setValue] = useState('') + const [areaValue, setAreaValue] = useState('') + const [viewFormat, setViewFormat] = useState(viewFormatProp) + const [isValid, setIsValid] = useState(true) + const [isDisabled, setIsDisabled] = useState(false) + const [isEditable, setIsEditable] = useState(true) + const [noEditableText, setNoEditableText] = useState(TEXT_DISABLED_COMPRESSED_VALUE) + + const textAreaRef: Ref = useRef(null) + const viewValueRef: Ref = useRef(null) + + const dispatch = useDispatch() + + useEffect(() => () => { + dispatch(resetStringValue()) + }, []) + + useEffect(() => { + if (!initialValue) return + + const { value: decompressedValue, isCompressed } = decompressingBuffer(initialValue, compressor) + + const initialValueString = bufferToString(decompressedValue, viewFormat) + const { value: formattedValue, isValid } = formattingBuffer(decompressedValue, viewFormatProp, { expanded: true }) + setAreaValue(initialValueString) + + setValue(!isFullStringLoaded(initialValue?.data?.length, length) ? `${formattedValue}...` : formattedValue) + setIsValid(isValid) + setIsDisabled( + !isNonUnicodeFormatter(viewFormatProp, isValid) + && !isEqualBuffers(initialValue, stringToBuffer(initialValueString)) + ) + setIsEditable( + !isCompressed + && isFormatEditable(viewFormatProp) + && isFullStringLoaded(initialValue?.data?.length, length) + ) + setNoEditableText(isCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_FAILED_CONVENT_FORMATTER(viewFormatProp)) + + dispatch(setIsStringCompressed(isCompressed)) + + if (viewFormat !== viewFormatProp) { + setViewFormat(viewFormatProp) + } + }, [initialValue, viewFormatProp, compressor, length]) + + useEffect(() => { + // Approximate calculation of textarea rows by initialValue + if (!isEditItem || !textAreaRef.current || value === null) { + return + } + const calculatedRows = calculateTextareaLines(areaValue, textAreaRef.current.clientWidth, APPROXIMATE_WIDTH_OF_SIGN) + + if (calculatedRows > MAX_ROWS) { + setRows(MAX_ROWS) + return + } + if (calculatedRows < MIN_ROWS) { + setRows(MIN_ROWS) + return + } + setRows(calculatedRows) + }, [viewValueRef, isEditItem]) + + useMemo(() => { + if (isEditItem && initialValue) { + (document.activeElement as HTMLElement)?.blur() + setAreaValue(bufferToSerializedFormat(viewFormat, initialValue, 4)) + } + }, [isEditItem]) + + const onApplyChanges = () => { + const data = stringToSerializedBufferFormat(viewFormat, areaValue) + const onSuccess = () => { + setIsEdit(false) + setValue(formattingBuffer(data, viewFormat, { expanded: true })?.value) + } + dispatch(updateStringValueAction(key, data, onSuccess)) + } + + const onDeclineChanges = useCallback(() => { + if (!initialValue) return + + setAreaValue(bufferToSerializedFormat(viewFormat, initialValue, 4)) + setIsEdit(false) + }, [initialValue]) + + const isLoading = loading || value === null + + const handleLoadAll = (key: RedisResponseBuffer, type: KeyTypes | ModulesKeyTypes) => { + const endString = length - 1 + onRefresh(key, type, { end: endString }) + sendEventTelemetry({ + event: TelemetryEvent.STRING_LOAD_ALL_CLICKED, + eventData: { + databaseId: instanceId, + length, + } + }) + } + + const handleDownloadString = (e: React.MouseEvent) => { + e.preventDefault() + dispatch(fetchDownloadStringValue(key, downloadFile)) + sendEventTelemetry({ + event: TelemetryEvent.STRING_DOWNLOAD_VALUE_CLICKED, + eventData: { + databaseId: instanceId, + length, + } + }) + } + + return ( + <> +
+ {isLoading && ( + + )} + {!isEditItem && ( + isEditable && setIsEdit(true)} + style={{ whiteSpace: 'break-spaces' }} + data-testid="string-value" + > + {areaValue !== '' + ? (isValid + ? value + : ( + + <>{value} + + ) + ) + : (!isLoading && (Empty))} + + )} + {isEditItem && ( + + formattingBuffer( + stringToSerializedBufferFormat(viewFormat, areaValue), + viewFormat + )?.isValid} + > + ) => { + setAreaValue(e.target.value) + }} + disabled={loading} + inputRef={textAreaRef} + className={cx(styles.stringTextArea, { [styles.areaWarning]: isDisabled })} + data-testid="string-value" + /> + + )} +
+ + {length > MAX_LENGTH && ( +
+ + + {!isFullStringLoaded(initialValue?.data?.length, length) && ( + handleLoadAll(key, keyType)} + > + Load all + + )} + + + + Download + + + +
+ )} + + ) +} + +export { StringDetailsTable } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/index.ts new file mode 100644 index 0000000000..f62de5cbed --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/index.ts @@ -0,0 +1 @@ +export { StringDetailsTable } from './StringDetailsTable' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.spec.tsx index 9cf6367abf..3c0c3e3aa1 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.spec.tsx @@ -1,100 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { zsetDataSelector } from 'uiSrc/slices/browser/zset' -import { anyToBuffer } from 'uiSrc/utils' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import { GZIP_COMPRESSED_VALUE_1, DECOMPRESSED_VALUE_STR_1 } from 'uiSrc/utils/tests/decompressors' -import ZSetDetails, { Props } from './ZSetDetails' +import { render } from 'uiSrc/utils/test-utils' +import { Props, ZSetDetails } from './ZSetDetails' const mockedProps = mock() -jest.mock('uiSrc/slices/browser/zset', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/zset').initialState - return ({ - zsetSelector: jest.fn().mockReturnValue(defaultState), - setZsetInitialState: jest.fn, - zsetDataSelector: jest.fn().mockReturnValue({ - ...defaultState, - total: 4, - key: 'z', - keyName: 'z', - members: [ - { name: { type: 'Buffer', data: [49] }, score: 1 }, - { name: { type: 'Buffer', data: [50] }, score: 2 }, - { name: { type: 'Buffer', data: [51] }, score: 3 }, - { name: { type: 'Buffer', data: [52] }, score: 'inf' }, - ], - }), - updateZsetScoreStateSelector: jest.fn().mockReturnValue(defaultState.updateScore), - fetchSearchZSetMembers: () => jest.fn() - }) -}) - describe('ZSetDetails', () => { it('should render', () => { expect(render()).toBeTruthy() }) - - it('should render search input', () => { - render() - expect(screen.getByPlaceholderText(/search/i)).toBeTruthy() - }) - - it('should call search', () => { - render() - const searchInput = screen.getByPlaceholderText(/search/i) - fireEvent.change( - searchInput, - { target: { value: '*' } } - ) - expect(searchInput).toHaveValue('*') - }) - - it('should render delete popup after click remove button', () => { - render() - fireEvent.click(screen.getAllByTestId(/zset-edit-button/)[0]) - expect(screen.getByTestId(/zset-edit-button-1/)).toBeInTheDocument() - }) - - it('should render disabled edit button', () => { - render() - expect(screen.getByTestId(/zset-edit-button-4/)).toBeDisabled() - }) - - it('should render enabled edit button', () => { - render() - expect(screen.getByTestId(/zset-edit-button-3/)).not.toBeDisabled() - }) - - it('should render editor after click edit button and able to change value', () => { - render() - fireEvent.click(screen.getAllByTestId(/zset-edit-button/)[0]) - expect(screen.getByTestId('inline-item-editor')).toBeInTheDocument() - 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() - }) - - describe('decompressed data', () => { - it('should render decompressed GZIP data = "1"', () => { - const defaultState = jest.requireActual('uiSrc/slices/browser/zset').initialState - const zsetDataSelectorMock = jest.fn().mockReturnValue({ - ...defaultState, - key: '123zxczxczxc', - members: [ - { name: anyToBuffer(GZIP_COMPRESSED_VALUE_1), score: 1 }, - ] - }) - zsetDataSelector.mockImplementation(zsetDataSelectorMock) - - const { queryByTestId } = render() - const memberEl = queryByTestId(/zset-member-value-/) - - expect(memberEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) - }) - }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx index d3fb3f5f1d..f6a1760f18 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx @@ -1,471 +1,64 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -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 React, { useState } from 'react' +import { useSelector } from 'react-redux' import { - zsetSelector, - fetchZSetMembers, - fetchMoreZSetMembers, - zsetDataSelector, - deleteZSetMembers, - updateZsetScoreStateSelector, - updateZSetMembers, - fetchSearchZSetMembers, - fetchSearchMoreZSetMembers, -} from 'uiSrc/slices/browser/zset' -import { KeyTypes, OVER_RENDER_BUFFER_COUNT, SortOrder, TableCellAlignment, TEXT_FAILED_CONVENT_FORMATTER } 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 { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' -import { ZsetMember } from 'uiSrc/slices/interfaces/zset' -import { - bufferToString, - createDeleteFieldHeader, - createDeleteFieldMessage, - formatLongName, - formattingBuffer, - isEqualBuffers, - validateScoreNumber -} from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' -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, RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' -import { StopPropagation } from 'uiSrc/components/virtual-table' -import { getColumnWidth } from 'uiSrc/components/virtual-grid' -import { decompressingBuffer } from 'uiSrc/utils/decompressors' -import { AddMembersToZSetDto, SearchZSetMembersResponse } from 'apiSrc/modules/browser/dto' -import PopoverDelete from '../../../../components/popover-delete/PopoverDelete' - -import styles from './styles.module.scss' + selectedKeySelector, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes } from 'uiSrc/constants' -const suffix = '_zset' -const headerHeight = 60 -const rowHeight = 43 +import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' +import { ZSetDetailsTable } from './zset-details-table' -const cellCache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: rowHeight, -}) +import { AddItemsPanel } from '../add-items-panel' -interface IZsetMember extends ZsetMember { - editing: boolean; -} - -export interface Props { - isFooterOpen: boolean +export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void + onOpenAddItemPanel: () => void + onCloseAddItemPanel: () => void } const ZSetDetails = (props: Props) => { - const { isFooterOpen, onRemoveKey } = props - - const { loading, searching } = useSelector(zsetSelector) - const { loading: updateLoading } = useSelector(updateZsetScoreStateSelector) - const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.ASC) - const { name: key, length } = useSelector(selectedKeyDataSelector) ?? { name: '' } - const { total, nextCursor, members: loadedMembers } = useSelector(zsetDataSelector) - const { id: instanceId, compressor = null } = 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('') - const [members, setMembers] = useState([]) - const [sortedColumnName, setSortedColumnName] = useState('score') - const [width, setWidth] = useState(100) - const [expandedRows, setExpandedRows] = useState([]) - const [viewFormat, setViewFormat] = useState(viewFormatProp) - - const dispatch = useDispatch() - - const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) - - useEffect(() => { - const newMembers = loadedMembers.map((item) => ({ - ...item, - editing: false, - })) - - setMembers(newMembers) - - if (loadedMembers.length < members.length) { - formattedLastIndexRef.current = 0 - } - - if (viewFormat !== viewFormatProp) { - setExpandedRows([]) - setViewFormat(viewFormatProp) - - cellCache.clearAll() - setTimeout(() => { - cellCache.clearAll() - }, 0) - } - }, [loadedMembers, viewFormatProp]) - - const closePopover = useCallback(() => { - setDeleting('') - }, []) - - const showPopover = useCallback((member = '') => { - setDeleting(`${member + suffix}`) - }, []) - - const onSuccessRemoved = (newTotal: number) => { - newTotal === 0 && onRemoveKey() - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.ZSet, - numberOfRemoved: 1, - } - }) - } - - const handleDeleteMember = (member: RedisString | string = '') => { - dispatch(deleteZSetMembers(key, [member], onSuccessRemoved)) - closePopover() - } + const keyType = KeyTypes.ZSet + const { onRemoveKey, onOpenAddItemPanel, onCloseAddItemPanel } = props - const handleEditMember = (name: RedisResponseBuffer, editing: boolean) => { - const newMemberState = members.map((item) => { - if (isEqualBuffers(item.name, name)) { - return { ...item, editing } - } - return item - }) - setMembers(newMemberState) - cellCache.clearAll() - } - - const handleApplyEditScore = (name: RedisResponseBuffer, score: string = '') => { - const data: AddMembersToZSetDto = { - keyName: key, - members: [{ - name, - score: toNumber(score), - }] - } - dispatch( - updateZSetMembers( - data, - () => handleEditMember(name, false) - ) - ) - } - - const handleRemoveIconClick = () => { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.ZSet - } - }) - } + const { loading } = useSelector(selectedKeySelector) - const handleSearch = (search: IColumnSearchState[]) => { - const fieldColumn = search.find((column) => column.id === 'name') - if (!fieldColumn) { return } + const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) - const { value: match } = fieldColumn - const onSuccess = (data: SearchZSetMembersResponse) => { - const matchValue = getMatchType(match) - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED - ), - eventData: { - databaseId: instanceId, - keyType: KeyTypes.ZSet, - match: matchValue, - length: data.total, - } - }) - } - setMatch(match) - if (match === '') { - dispatch( - fetchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, sortedColumnOrder) - ) - return - } - dispatch( - fetchSearchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, match, onSuccess) - ) + const openAddItemPanel = () => { + setIsAddItemPanelOpen(true) + onOpenAddItemPanel() } - const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { - const browserViewEvent = expanded - ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED - const treeViewEvent = expanded - ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED - : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED - - sendEventTelemetry({ - event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), - eventData: { - keyType: KeyTypes.ZSet, - databaseId: instanceId, - largestCellLength: members[rowIndex]?.name?.length || 0, - } - }) - - cellCache.clearAll() - } - - const onColResizeEnd = (sizes: RelativeWidthSizes) => { - dispatch(updateKeyDetailsSizes({ - type: KeyTypes.ZSet, - sizes - })) - } - - const columns:ITableColumn[] = [ - { - id: 'name', - label: 'Member', - isSearchable: true, - 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', - render: function Name(_name: string, { name: nameItem }: IZsetMember, expanded?: boolean) { - const { value: decompressedNameItem } = decompressingBuffer(nameItem, compressor) - const name = bufferToString(nameItem) - const tooltipContent = formatLongName(name) - const { value, isValid } = formattingBuffer(decompressedNameItem, viewFormat, { expanded }) - const cellContent = value?.substring?.(0, 200) ?? value - - return ( - -
- {!expanded && ( - - <>{cellContent} - - )} - {expanded && value} -
-
- ) - }, - }, - { - id: 'score', - label: 'Score', - minWidth: 100, - isSortable: true, - truncateText: true, - render: function Score(_name: string, { name: nameItem, score, editing }: IZsetMember, expanded?: boolean) { - const cellContent = score.toString().substring(0, 200) - const tooltipContent = formatLongName(score.toString()) - if (editing) { - return ( - - handleEditMember(nameItem, false)} - onApply={(value) => handleApplyEditScore(nameItem, value)} - validation={validateScoreNumber} - /> - - ) - } - return ( - -
- {!expanded && ( - - <>{cellContent} - - )} - {expanded && score} -
-
- ) - } - }, - { - id: 'actions', - label: '', - headerClassName: 'value-table-header-actions', - className: 'actions', - minWidth: 100, - maxWidth: 100, - absoluteWidth: 100, - render: function Actions(_act: any, { name: nameItem, score }: IZsetMember) { - const name = bufferToString(nameItem, viewFormat) - return ( - -
- - handleEditMember(nameItem, true)} - data-testid={`zset-edit-button-${name}`} - /> - - -
-
- ) - }, - }, - ] - - const onChangeSorting = (column: any, order: SortOrder) => { - setSortedColumnName(column) - setSortedColumnOrder(order) - - dispatch(fetchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, order)) - } - - const loadMoreItems = ({ startIndex, stopIndex }: any) => { - if (!searching) { - dispatch( - fetchMoreZSetMembers( - key, - startIndex, - stopIndex - startIndex + 1, - sortedColumnOrder - ) - ) - return - } - if (nextCursor !== 0) { - dispatch( - fetchSearchMoreZSetMembers( - key, - nextCursor, - SCAN_COUNT_DEFAULT, - match - ) - ) - } - } - - const sortedColumn = { - column: sortedColumnName, - order: sortedColumnOrder, + const closeAddItemPanel = () => { + setIsAddItemPanelOpen(false) + onCloseAddItemPanel() } return ( - <> -
+ +
+ {!loading && ( +
+ +
)} - > - {loading && ( - )} - ({ - ...column, - width: getColumnWidth(i, width, arr) - }))} - footerHeight={0} - loadMoreItems={loadMoreItems} - loading={loading} - searching={searching} - items={members} - sortedColumn={sortedColumn} - onChangeSorting={onChangeSorting} - totalItemsCount={total} - noItemsMessage={NoResultsFoundText} - onWheel={closePopover} - onSearch={handleSearch} - cellCache={cellCache} - onRowToggleViewClick={handleRowToggleViewClick} - expandedRows={expandedRows} - setExpandedRows={setExpandedRows} - onColResizeEnd={onColResizeEnd} - />
- +
+ ) } -export default ZSetDetails +export { ZSetDetails } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/index.ts new file mode 100644 index 0000000000..a69b5eb401 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/index.ts @@ -0,0 +1 @@ +export { ZSetDetails } from './ZSetDetails' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.spec.tsx new file mode 100644 index 0000000000..5ee59f5c38 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { zsetDataSelector } from 'uiSrc/slices/browser/zset' +import { anyToBuffer } from 'uiSrc/utils' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { GZIP_COMPRESSED_VALUE_1, DECOMPRESSED_VALUE_STR_1 } from 'uiSrc/utils/tests/decompressors' +import { ZSetDetailsTable, Props } from './ZSetDetailsTable' + +const mockedProps = mock() + +jest.mock('uiSrc/slices/browser/zset', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/zset').initialState + return ({ + zsetSelector: jest.fn().mockReturnValue(defaultState), + setZsetInitialState: jest.fn, + zsetDataSelector: jest.fn().mockReturnValue({ + ...defaultState, + total: 4, + key: 'z', + keyName: 'z', + members: [ + { name: { type: 'Buffer', data: [49] }, score: 1 }, + { name: { type: 'Buffer', data: [50] }, score: 2 }, + { name: { type: 'Buffer', data: [51] }, score: 3 }, + { name: { type: 'Buffer', data: [52] }, score: 'inf' }, + ], + }), + updateZsetScoreStateSelector: jest.fn().mockReturnValue(defaultState.updateScore), + fetchSearchZSetMembers: () => jest.fn() + }) +}) + +describe('ZSetDetailsTable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render search input', () => { + render() + expect(screen.getByPlaceholderText(/search/i)).toBeTruthy() + }) + + it('should call search', () => { + render() + const searchInput = screen.getByPlaceholderText(/search/i) + fireEvent.change( + searchInput, + { target: { value: '*' } } + ) + expect(searchInput).toHaveValue('*') + }) + + it('should render delete popup after click remove button', () => { + render() + fireEvent.click(screen.getAllByTestId(/zset-edit-button/)[0]) + expect(screen.getByTestId(/zset-edit-button-1/)).toBeInTheDocument() + }) + + it('should render disabled edit button', () => { + render() + expect(screen.getByTestId(/zset-edit-button-4/)).toBeDisabled() + }) + + it('should render enabled edit button', () => { + render() + expect(screen.getByTestId(/zset-edit-button-3/)).not.toBeDisabled() + }) + + it('should render editor after click edit button and able to change value', () => { + render() + fireEvent.click(screen.getAllByTestId(/zset-edit-button/)[0]) + expect(screen.getByTestId('inline-item-editor')).toBeInTheDocument() + 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() + }) + + describe('decompressed data', () => { + it('should render decompressed GZIP data = "1"', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/zset').initialState + const zsetDataSelectorMock = jest.fn().mockReturnValue({ + ...defaultState, + key: '123zxczxczxc', + members: [ + { name: anyToBuffer(GZIP_COMPRESSED_VALUE_1), score: 1 }, + ] + }) + zsetDataSelector.mockImplementation(zsetDataSelectorMock) + + const { queryByTestId } = render() + const memberEl = queryByTestId(/zset-member-value-/) + + expect(memberEl).toHaveTextContent(DECOMPRESSED_VALUE_STR_1) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.tsx new file mode 100644 index 0000000000..b969fba9cc --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/ZSetDetailsTable.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +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, + fetchZSetMembers, + fetchMoreZSetMembers, + zsetDataSelector, + deleteZSetMembers, + updateZsetScoreStateSelector, + updateZSetMembers, + fetchSearchZSetMembers, + fetchSearchMoreZSetMembers, +} from 'uiSrc/slices/browser/zset' +import { KeyTypes, OVER_RENDER_BUFFER_COUNT, SortOrder, TableCellAlignment, TEXT_FAILED_CONVENT_FORMATTER } 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 { selectedKeyDataSelector, keysSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces' +import { ZsetMember } from 'uiSrc/slices/interfaces/zset' +import { + bufferToString, + createDeleteFieldHeader, + createDeleteFieldMessage, + formatLongName, + formattingBuffer, + isEqualBuffers, + validateScoreNumber +} from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' +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, RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' +import { StopPropagation } from 'uiSrc/components/virtual-table' +import { getColumnWidth } from 'uiSrc/components/virtual-grid' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' +import { AddMembersToZSetDto, SearchZSetMembersResponse } from 'apiSrc/modules/browser/dto' +import PopoverDelete from '../../../../../components/popover-delete/PopoverDelete' + +import styles from './styles.module.scss' + +const suffix = '_zset' +const headerHeight = 60 +const rowHeight = 43 + +const cellCache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: rowHeight, +}) + +interface IZsetMember extends ZsetMember { + editing: boolean; +} + +export interface Props { + isFooterOpen: boolean + onRemoveKey: () => void +} + +const ZSetDetailsTable = (props: Props) => { + const { isFooterOpen, onRemoveKey } = props + + const { loading, searching } = useSelector(zsetSelector) + const { loading: updateLoading } = useSelector(updateZsetScoreStateSelector) + const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.ASC) + const { name: key, length } = useSelector(selectedKeyDataSelector) ?? { name: '' } + const { total, nextCursor, members: loadedMembers } = useSelector(zsetDataSelector) + const { id: instanceId, compressor = null } = 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('') + const [members, setMembers] = useState([]) + const [sortedColumnName, setSortedColumnName] = useState('score') + const [width, setWidth] = useState(100) + const [expandedRows, setExpandedRows] = useState([]) + const [viewFormat, setViewFormat] = useState(viewFormatProp) + + const dispatch = useDispatch() + + const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) + + useEffect(() => { + const newMembers = loadedMembers.map((item) => ({ + ...item, + editing: false, + })) + + setMembers(newMembers) + + if (loadedMembers.length < members.length) { + formattedLastIndexRef.current = 0 + } + + if (viewFormat !== viewFormatProp) { + setExpandedRows([]) + setViewFormat(viewFormatProp) + + cellCache.clearAll() + setTimeout(() => { + cellCache.clearAll() + }, 0) + } + }, [loadedMembers, viewFormatProp]) + + const closePopover = useCallback(() => { + setDeleting('') + }, []) + + const showPopover = useCallback((member = '') => { + setDeleting(`${member + suffix}`) + }, []) + + const onSuccessRemoved = (newTotal: number) => { + newTotal === 0 && onRemoveKey() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.ZSet, + numberOfRemoved: 1, + } + }) + } + + const handleDeleteMember = (member: RedisString | string = '') => { + dispatch(deleteZSetMembers(key, [member], onSuccessRemoved)) + closePopover() + } + + const handleEditMember = (name: RedisResponseBuffer, editing: boolean) => { + const newMemberState = members.map((item) => { + if (isEqualBuffers(item.name, name)) { + return { ...item, editing } + } + return item + }) + setMembers(newMemberState) + cellCache.clearAll() + } + + const handleApplyEditScore = (name: RedisResponseBuffer, score: string = '') => { + const data: AddMembersToZSetDto = { + keyName: key, + members: [{ + name, + score: toNumber(score), + }] + } + dispatch( + updateZSetMembers( + data, + () => handleEditMember(name, false) + ) + ) + } + + const handleRemoveIconClick = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.ZSet + } + }) + } + + const handleSearch = (search: IColumnSearchState[]) => { + const fieldColumn = search.find((column) => column.id === 'name') + if (!fieldColumn) { return } + + const { value: match } = fieldColumn + const onSuccess = (data: SearchZSetMembersResponse) => { + const matchValue = getMatchType(match) + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_FILTERED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_FILTERED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.ZSet, + match: matchValue, + length: data.total, + } + }) + } + setMatch(match) + if (match === '') { + dispatch( + fetchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, sortedColumnOrder) + ) + return + } + dispatch( + fetchSearchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, match, onSuccess) + ) + } + + const handleRowToggleViewClick = (expanded: boolean, rowIndex: number) => { + const browserViewEvent = expanded + ? TelemetryEvent.BROWSER_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.BROWSER_KEY_FIELD_VALUE_COLLAPSED + const treeViewEvent = expanded + ? TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_EXPANDED + : TelemetryEvent.TREE_VIEW_KEY_FIELD_VALUE_COLLAPSED + + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + keyType: KeyTypes.ZSet, + databaseId: instanceId, + largestCellLength: members[rowIndex]?.name?.length || 0, + } + }) + + cellCache.clearAll() + } + + const onColResizeEnd = (sizes: RelativeWidthSizes) => { + dispatch(updateKeyDetailsSizes({ + type: KeyTypes.ZSet, + sizes + })) + } + + const columns:ITableColumn[] = [ + { + id: 'name', + label: 'Member', + isSearchable: true, + 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', + render: function Name(_name: string, { name: nameItem }: IZsetMember, expanded?: boolean) { + const { value: decompressedNameItem } = decompressingBuffer(nameItem, compressor) + const name = bufferToString(nameItem) + const tooltipContent = formatLongName(name) + const { value, isValid } = formattingBuffer(decompressedNameItem, viewFormat, { expanded }) + const cellContent = value?.substring?.(0, 200) ?? value + + return ( + +
+ {!expanded && ( + + <>{cellContent} + + )} + {expanded && value} +
+
+ ) + }, + }, + { + id: 'score', + label: 'Score', + minWidth: 100, + isSortable: true, + truncateText: true, + render: function Score(_name: string, { name: nameItem, score, editing }: IZsetMember, expanded?: boolean) { + const cellContent = score.toString().substring(0, 200) + const tooltipContent = formatLongName(score.toString()) + if (editing) { + return ( + + handleEditMember(nameItem, false)} + onApply={(value) => handleApplyEditScore(nameItem, value)} + validation={validateScoreNumber} + /> + + ) + } + return ( + +
+ {!expanded && ( + + <>{cellContent} + + )} + {expanded && score} +
+
+ ) + } + }, + { + id: 'actions', + label: '', + headerClassName: 'value-table-header-actions', + className: 'actions', + minWidth: 100, + maxWidth: 100, + absoluteWidth: 100, + render: function Actions(_act: any, { name: nameItem, score }: IZsetMember) { + const name = bufferToString(nameItem, viewFormat) + return ( + +
+ + handleEditMember(nameItem, true)} + data-testid={`zset-edit-button-${name}`} + /> + + +
+
+ ) + }, + }, + ] + + const onChangeSorting = (column: any, order: SortOrder) => { + setSortedColumnName(column) + setSortedColumnOrder(order) + + dispatch(fetchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, order)) + } + + const loadMoreItems = ({ startIndex, stopIndex }: any) => { + if (!searching) { + dispatch( + fetchMoreZSetMembers( + key, + startIndex, + stopIndex - startIndex + 1, + sortedColumnOrder + ) + ) + return + } + if (nextCursor !== 0) { + dispatch( + fetchSearchMoreZSetMembers( + key, + nextCursor, + SCAN_COUNT_DEFAULT, + match + ) + ) + } + } + + const sortedColumn = { + column: sortedColumnName, + order: sortedColumnOrder, + } + + return ( + <> +
+ {loading && ( + + )} + ({ + ...column, + width: getColumnWidth(i, width, arr) + }))} + footerHeight={0} + loadMoreItems={loadMoreItems} + loading={loading} + searching={searching} + items={members} + sortedColumn={sortedColumn} + onChangeSorting={onChangeSorting} + totalItemsCount={total} + noItemsMessage={NoResultsFoundText} + onWheel={closePopover} + onSearch={handleSearch} + cellCache={cellCache} + onRowToggleViewClick={handleRowToggleViewClick} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + onColResizeEnd={onColResizeEnd} + /> +
+ + ) +} + +export { ZSetDetailsTable } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/index.ts new file mode 100644 index 0000000000..b0e76e59ba --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/index.ts @@ -0,0 +1 @@ +export { ZSetDetailsTable } from './ZSetDetailsTable' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/zset-details-table/styles.module.scss From 3b4f12cf54e2fea321d5ab7bec4956c016836da7 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 14 Nov 2023 16:23:15 +0100 Subject: [PATCH 45/96] #RI-5033 - Refactor key details version 2 --- .../browser/keyDetailsHeader.ts} | 0 redisinsight/ui/src/constants/index.ts | 1 + redisinsight/ui/src/constants/keys.ts | 32 ---- .../add-key/AddKeyHash/AddKeyHash.tsx | 6 +- .../add-key/AddKeySet/AddKeySet.tsx | 6 +- .../add-key/AddKeyStream/AddKeyStream.tsx | 2 +- .../add-key/AddKeyZset/AddKeyZset.tsx | 6 +- .../KeyDetailsHeader.spec.tsx | 34 ---- .../key-details-header/KeyDetailsHeader.tsx | 24 +-- .../KeyDetailsHeaderActions.spec.tsx | 32 ---- .../KeyDetailsHeaderActions.tsx | 155 ------------------ .../key-details-header-actions/index.ts | 1 - .../KeyDetailsHeaderFormatter.tsx | 3 +- .../KeyDetailsHeaderSizeLength.tsx | 3 +- .../key-details-header/constants/index.ts | 1 - .../add-items-panel/AddItemsPanel.spec.tsx | 35 ---- .../add-items-panel/AddItemsPanel.tsx | 57 ------- .../components/add-items-panel/index.ts | 1 - .../add-items-panel/styles.module.scss | 4 - .../components/hash-details/HashDetails.tsx | 18 +- .../add-hash-fields/AddHashFields.spec.tsx | 0 .../add-hash-fields/AddHashFields.tsx | 2 +- .../add-items-action/AddItemsAction.spec.tsx | 12 ++ .../add-items-action/AddItemsAction.tsx | 45 +++++ .../edit-item-action/EditItemAction.spec.tsx | 12 ++ .../edit-item-action/EditItemAction.tsx | 32 ++++ .../components/key-details-actions/index.ts | 4 + .../RemoveItemsAction.spec.tsx | 12 ++ .../remove-items-action/RemoveItemsAction.tsx | 27 +++ .../StreamItemsAction.spec.tsx | 12 ++ .../stream-items-action/StreamItemsAction.tsx | 45 +++++ .../key-details-actions}/styles.module.scss | 0 .../components/key-details-add-items/index.ts | 25 --- .../key-details-add-items/styles.module.scss | 10 -- .../key-details-remove-items/index.ts | 1 - .../components/list-details/ListDetails.tsx | 26 +-- .../AddListElements.spec.tsx | 0 .../add-list-elements/AddListElements.tsx | 3 +- .../RemoveListElements.spec.tsx | 2 +- .../RemoveListElements.tsx | 6 +- .../remove-list-elements/index.ts | 1 + .../remove-list-elements}/styles.module.scss | 0 .../components/set-details/SetDetails.tsx | 18 +- .../add-set-members/AddSetMembers.spec.tsx | 2 +- .../add-set-members/AddSetMembers.tsx | 10 +- .../set-details/add-set-members/index.ts | 1 + .../stream-details/StreamDetails.spec.tsx | 46 +++++- .../stream-details/StreamDetails.tsx | 32 +++- .../AddStreamEntries.spec.tsx | 0 .../add-stream-entity/AddStreamEntries.tsx | 2 +- .../StreamEntryFields.spec.tsx | 0 .../StreamEntryFields/StreamEntryFields.tsx | 0 .../add-stream-entity/index.ts | 0 .../add-stream-entity/styles.module.scss | 0 .../add-stream-group/AddStreamGroup.spec.tsx | 0 .../add-stream-group/AddStreamGroup.tsx | 0 .../add-stream-group/index.ts | 0 .../add-stream-group/styles.module.scss | 0 .../string-details/StringDetails.spec.tsx | 61 ++++++- .../string-details/StringDetails.tsx | 35 +++- .../components/zset-details/ZSetDetails.tsx | 18 +- .../add-zset-members/AddZsetMembers.spec.tsx | 0 .../add-zset-members/AddZsetMembers.tsx | 8 +- .../modules/key-details/styles.module.scss | 12 ++ .../ui/src/styles/components/_forms.scss | 5 + 65 files changed, 459 insertions(+), 489 deletions(-) rename redisinsight/ui/src/{pages/browser/modules/key-details-header/constants/resolutions.ts => constants/browser/keyDetailsHeader.ts} (100%) delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => hash-details}/add-hash-fields/AddHashFields.spec.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => hash-details}/add-hash-fields/AddHashFields.tsx (98%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.tsx rename redisinsight/ui/src/pages/browser/modules/{key-details-header/components/key-details-header-actions => key-details/components/key-details-actions}/styles.module.scss (100%) delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/styles.module.scss delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => list-details}/add-list-elements/AddListElements.spec.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => list-details}/add-list-elements/AddListElements.tsx (97%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-remove-items => list-details}/remove-list-elements/RemoveListElements.spec.tsx (96%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-remove-items => list-details}/remove-list-elements/RemoveListElements.tsx (97%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-remove-items => list-details/remove-list-elements}/styles.module.scss (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => set-details}/add-set-members/AddSetMembers.spec.tsx (97%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => set-details}/add-set-members/AddSetMembers.tsx (94%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-entity/AddStreamEntries.spec.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-entity/AddStreamEntries.tsx (97%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-entity/index.ts (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-entity/styles.module.scss (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-group/AddStreamGroup.spec.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-group/AddStreamGroup.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-group/index.ts (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => stream-details}/add-stream-group/styles.module.scss (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => zset-details}/add-zset-members/AddZsetMembers.spec.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/{key-details-add-items => zset-details}/add-zset-members/AddZsetMembers.tsx (95%) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/resolutions.ts b/redisinsight/ui/src/constants/browser/keyDetailsHeader.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details-header/constants/resolutions.ts rename to redisinsight/ui/src/constants/browser/keyDetailsHeader.ts diff --git a/redisinsight/ui/src/constants/index.ts b/redisinsight/ui/src/constants/index.ts index caac0c1151..6ae5c0a6c8 100644 --- a/redisinsight/ui/src/constants/index.ts +++ b/redisinsight/ui/src/constants/index.ts @@ -31,4 +31,5 @@ export * from './serverVersions' export * from './customErrorCodes' export * from './securityField' export * from './redisearch' +export * from './browser/keyDetailsHeader' export { ApiEndpoints, BrowserStorageItem, ApiStatusCode, apiErrors } diff --git a/redisinsight/ui/src/constants/keys.ts b/redisinsight/ui/src/constants/keys.ts index 94310dd617..1ae5ccaafe 100644 --- a/redisinsight/ui/src/constants/keys.ts +++ b/redisinsight/ui/src/constants/keys.ts @@ -90,38 +90,6 @@ export type KeyTypesActions = { } } -export const KEY_TYPES_ACTIONS: KeyTypesActions = Object.freeze({ - [KeyTypes.Hash]: { - addItems: { - name: 'Add Fields', - }, - }, - [KeyTypes.List]: { - addItems: { - name: 'Add Element', - }, - removeItems: { - name: 'Remove Elements', - }, - }, - [KeyTypes.Set]: { - addItems: { - name: 'Add Members', - }, - }, - [KeyTypes.ZSet]: { - addItems: { - name: 'Add Members', - }, - }, - [KeyTypes.String]: { - editItem: { - name: 'Edit Value', - }, - }, - [KeyTypes.ReJSON]: {} -}) - export const STREAM_ADD_GROUP_VIEW_TYPES = [ StreamViewType.Groups, StreamViewType.Consumers, diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx index b8a95585d3..0f643813e1 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx @@ -14,13 +14,11 @@ import { import { addHashKey, addKeyStateSelector, } from 'uiSrc/slices/browser/keys' -import { - IHashFieldState, - INITIAL_HASH_FIELD_STATE -} from 'uiSrc/pages/browser/modules/key-details/components/key-details-add-items' + import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' import { Maybe, stringToBuffer } from 'uiSrc/utils' +import { IHashFieldState, INITIAL_HASH_FIELD_STATE } from 'uiSrc/pages/browser/modules/key-details/components/hash-details/add-hash-fields/AddHashFields' import { CreateHashWithExpireDto } from 'apiSrc/modules/browser/dto/hash.dto' import { AddHashFormConfig as config diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx index 44c917ca45..465603ab8f 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx @@ -16,10 +16,8 @@ import { addSetKey, addKeyStateSelector, } from 'uiSrc/slices/browser/keys' import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' -import { - INITIAL_SET_MEMBER_STATE, - ISetMemberState -} from 'uiSrc/pages/browser/modules/key-details/components/key-details-add-items' + +import { INITIAL_SET_MEMBER_STATE, ISetMemberState } from 'uiSrc/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers' import { CreateSetWithExpireDto } from 'apiSrc/modules/browser/dto/set.dto' import { diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx index 5f77dd878f..e627d9afce 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx @@ -10,8 +10,8 @@ import { } from '@elastic/eui' import { addStreamKey } from 'uiSrc/slices/browser/keys' import { entryIdRegex, isRequiredStringsValid, Maybe, stringToBuffer } from 'uiSrc/utils' -import { StreamEntryFields } from 'uiSrc/pages/browser/modules/key-details/components/key-details-add-items' import { AddStreamFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { StreamEntryFields } from 'uiSrc/pages/browser/modules/key-details/components/stream-details/add-stream-entity' import { CreateStreamDto } from 'apiSrc/modules/browser/dto/stream.dto' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx index 6be206376a..4cc7ec7e73 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx @@ -17,10 +17,8 @@ import { isNaNConvertedString } from 'uiSrc/utils/numbers' import { addZsetKey, addKeyStateSelector } from 'uiSrc/slices/browser/keys' import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' -import { - INITIAL_ZSET_MEMBER_STATE, - IZsetMemberState -} from 'uiSrc/pages/browser/modules/key-details/components/key-details-add-items' + +import { INITIAL_ZSET_MEMBER_STATE, IZsetMemberState } from 'uiSrc/pages/browser/modules/key-details/components/zset-details/add-zset-members/AddZsetMembers' import { CreateZSetWithExpireDto } from 'apiSrc/modules/browser/dto/z-set.dto' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' import { AddZsetFormConfig as config } from '../constants/fields-config' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx index 9cce3d919f..87db0c037e 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx @@ -2,7 +2,6 @@ import React from 'react' import { mock } from 'ts-mockito' import { cloneDeep } from 'lodash' import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' -import { stringDataSelector } from 'uiSrc/slices/browser/string' import { KeyTypes } from 'uiSrc/constants' import { deleteSelectedKey } from 'uiSrc/slices/browser/keys' import { KeyDetailsHeaderProps, KeyDetailsHeader } from './KeyDetailsHeader' @@ -12,7 +11,6 @@ const mockedProps = mock() const KEY_INPUT_TEST_ID = 'edit-key-input' const KEY_BTN_TEST_ID = 'edit-key-btn' const TTL_INPUT_TEST_ID = 'edit-ttl-input' -const EDIT_VALUE_BTN_TEST_ID = 'edit-key-value-btn' const DELETE_KEY_BTN_TEST_ID = 'delete-key-btn' const DELETE_KEY_CONFIRM_BTN_TEST_ID = 'delete-key-confirm-btn' @@ -95,38 +93,6 @@ describe('KeyDetailsHeader', () => { expect(screen.getByTestId(TTL_INPUT_TEST_ID)).toHaveValue('100') }) - it('should be able to change value (long string fully load)', () => { - render( - - ) - - const editValueBtn = screen.getByTestId(`${EDIT_VALUE_BTN_TEST_ID}`) - expect(editValueBtn).toHaveProperty('disabled', false) - }) - - it('should not be able to change value (long string not fully load)', () => { - const stringDataSelectorMock = jest.fn().mockReturnValue({ - value: { - type: 'Buffer', - data: [49, 50, 51], - } - }) - stringDataSelector.mockImplementation(stringDataSelectorMock) - - render( - - ) - - const editValueBtn = screen.getByTestId(`${EDIT_VALUE_BTN_TEST_ID}`) - expect(editValueBtn).toHaveProperty('disabled', true) - }) - describe('should call onRefresh', () => { test.each(Object.values(KeyTypes))('should call onRefresh for keyType: %s', (keyType) => { const component = render() diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 534efbd346..0894603cc2 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -5,12 +5,13 @@ import { EuiLoadingContent, EuiToolTip, } from '@elastic/eui' -import React from 'react' +import React, { ReactElement } from 'react' import { useDispatch, useSelector } from 'react-redux' import AutoSizer from 'react-virtualized-auto-sizer' import { GroupBadge, AutoRefresh, FullScreen } from 'uiSrc/components' import { + HIDE_LAST_REFRESH, KeyTypes, ModulesKeyTypes, } from 'uiSrc/constants' @@ -29,13 +30,12 @@ import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { resetStringValue } from 'uiSrc/slices/browser/string' +import { Nullable } from 'uiSrc/utils' import { KeyDetailsHeaderFormatter } from './components/key-details-header-formatter' import { KeyDetailsHeaderName } from './components/key-details-header-name' import { KeyDetailsHeaderTTL } from './components/key-details-header-ttl' -import { KeyDetailsHeaderActions } from './components/key-details-header-actions' import { KeyDetailsHeaderDelete } from './components/key-details-header-delete' import { KeyDetailsHeaderSizeLength } from './components/key-details-header-size-length' -import { HIDE_LAST_REFRESH } from './constants' import styles from './styles.module.scss' @@ -44,12 +44,10 @@ export interface KeyDetailsHeaderProps { onCloseKey: (key: RedisResponseBuffer) => void onRemoveKey: () => void onEditKey: (key: RedisResponseBuffer, newKey: RedisResponseBuffer, onFailure?: () => void) => void - onAddItem?: () => void - onEditItem?: () => void - onRemoveItem?: () => void isFullScreen: boolean arePanelsCollapsed: boolean onToggleFullScreen: () => void + Actions: (props: { width: number }) => Nullable } const KeyDetailsHeader = ({ @@ -60,9 +58,7 @@ const KeyDetailsHeader = ({ onRemoveKey, onEditKey, keyType, - onAddItem = () => {}, - onEditItem = () => {}, - onRemoveItem = () => {}, + Actions = () => null, }: KeyDetailsHeaderProps) => { const { loading, lastRefreshTime } = useSelector(selectedKeySelector) const { @@ -190,15 +186,7 @@ const KeyDetailsHeader = ({ {Object.values(KeyTypes).includes(keyType as KeyTypes) && ( )} - {keyType && ( - - )} + {keyType && }
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx deleted file mode 100644 index 687d4068d3..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.spec.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' -import { KeyTypes } from 'uiSrc/constants' -import { Props, KeyDetailsHeaderActions } from './KeyDetailsHeaderActions' - -const mockedProps = mock() - -const actionsExistsTests: any[] = [ - [KeyTypes.Hash, ['add-key-value-items-btn']], - [KeyTypes.List, ['add-key-value-items-btn', 'remove-key-value-items-btn']], - [KeyTypes.Set, ['add-key-value-items-btn']], - [KeyTypes.ZSet, ['add-key-value-items-btn']], - [KeyTypes.String, ['edit-key-value-btn']], -] - -describe('KeyDetailsHeaderActions', () => { - it('should render', () => { - expect(render()).toBeTruthy() - }) - - test.each(actionsExistsTests)('for keyType: %s, actions test id should exist: %s', (keyType: KeyTypes, testIds: string[]) => { - const { queryByTestId } = render() - - testIds.forEach((testId) => { - expect(queryByTestId(testId)).toBeInTheDocument() - }) - }) -}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx deleted file mode 100644 index 333355f9f0..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/KeyDetailsHeaderActions.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import React from 'react' -import { - EuiButton, - EuiButtonIcon, - EuiToolTip, -} from '@elastic/eui' -import cx from 'classnames' -import { useSelector } from 'react-redux' - -import { - KEY_TYPES_ACTIONS, - KeyTypes, - ModulesKeyTypes, - STREAM_ADD_ACTION, - TEXT_DISABLED_COMPRESSED_VALUE, - TEXT_DISABLED_FORMATTER_EDITING, - TEXT_DISABLED_STRING_EDITING, -} from 'uiSrc/constants' -import { initialKeyInfo, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { streamSelector } from 'uiSrc/slices/browser/stream' -import { - Maybe, - isFormatEditable, - isFullStringLoaded, -} from 'uiSrc/utils' -import { stringDataSelector, stringSelector } from 'uiSrc/slices/browser/string' - -import { MIDDLE_SCREEN_RESOLUTION } from '../../constants' -import styles from './styles.module.scss' - -export interface Props { - keyType: KeyTypes | ModulesKeyTypes - width: Maybe - onAddItem?: () => void - onEditItem?: () => void - onRemoveItem?: () => void -} - -const KeyDetailsHeaderActions = ({ - width = 0, - keyType, - onAddItem = () => {}, - onEditItem = () => {}, - onRemoveItem = () => {}, -}: Props) => { - const { length } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo - const { value: keyValue } = useSelector(stringDataSelector) - const { isCompressed: isStringCompressed } = useSelector(stringSelector) - const { viewType: streamViewType } = useSelector(streamSelector) - const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) - - const isEditable = !isStringCompressed && isFormatEditable(viewFormatProp) - const isStringEditable = keyType === KeyTypes.String ? isFullStringLoaded(keyValue?.data?.length, length) : true - const noEditableText = isStringCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING - const editToolTip = !isEditable ? noEditableText : (!isStringEditable ? TEXT_DISABLED_STRING_EDITING : null) - - return ( - <> - {KEY_TYPES_ACTIONS[keyType] && 'addItems' in KEY_TYPES_ACTIONS[keyType] && ( - MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} - position="left" - anchorClassName={cx(styles.actionBtn, { [styles.iiu]: width > MIDDLE_SCREEN_RESOLUTION })} - > - <> - {width > MIDDLE_SCREEN_RESOLUTION ? ( - - {KEY_TYPES_ACTIONS[keyType].addItems?.name} - - ) : ( - - )} - - - )} - {keyType === KeyTypes.Stream && ( - MIDDLE_SCREEN_RESOLUTION ? '' : STREAM_ADD_ACTION[streamViewType].name} - position="left" - anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} - > - <> - {width > MIDDLE_SCREEN_RESOLUTION ? ( - - {STREAM_ADD_ACTION[streamViewType].name} - - ) : ( - - )} - - - )} - {KEY_TYPES_ACTIONS[keyType] && 'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( - - - - )} - {KEY_TYPES_ACTIONS[keyType] && 'editItem' in KEY_TYPES_ACTIONS[keyType] && ( -
- - - -
- )} - - ) -} - -export { KeyDetailsHeaderActions } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts deleted file mode 100644 index 84cdbd1c94..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { KeyDetailsHeaderActions } from './KeyDetailsHeaderActions' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx index f41a9e1073..a693b53814 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx @@ -4,7 +4,7 @@ import { EuiIcon, EuiSuperSelect, EuiSuperSelectOption, EuiText, EuiTextColor, E import { useDispatch, useSelector } from 'react-redux' import { useParams } from 'react-router-dom' -import { KeyTypes, KeyValueFormat, TEXT_DISABLED_STRING_FORMATTING, Theme } from 'uiSrc/constants' +import { KeyTypes, KeyValueFormat, MIDDLE_SCREEN_RESOLUTION, TEXT_DISABLED_STRING_FORMATTING, Theme } from 'uiSrc/constants' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { keysSelector, selectedKeyDataSelector, selectedKeySelector, setViewFormat } from 'uiSrc/slices/browser/keys' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' @@ -13,7 +13,6 @@ import FormattersDark from 'uiSrc/assets/img/icons/formatter_dark.svg' import { stringDataSelector } from 'uiSrc/slices/browser/string' import { isFullStringLoaded } from 'uiSrc/utils' import { getKeyValueFormatterOptions } from './constants' -import { MIDDLE_SCREEN_RESOLUTION } from '../../constants' import styles from './styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx index 427e2c2606..37fa8eb94e 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-size-length/KeyDetailsHeaderSizeLength.tsx @@ -6,11 +6,10 @@ import { import React from 'react' import { useSelector } from 'react-redux' -import { LENGTH_NAMING_BY_TYPE } from 'uiSrc/constants' +import { LENGTH_NAMING_BY_TYPE, MIDDLE_SCREEN_RESOLUTION } from 'uiSrc/constants' import { initialKeyInfo, selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' import { formatBytes } from 'uiSrc/utils' -import { MIDDLE_SCREEN_RESOLUTION } from '../../constants' import styles from './styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts deleted file mode 100644 index 0be943b3fe..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './resolutions' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx deleted file mode 100644 index 3a283c5894..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' -import { KeyTypes } from 'uiSrc/constants' -import { StreamViewType } from 'uiSrc/slices/interfaces/stream' -import { Props, AddItemsPanel } from './AddItemsPanel' - -const mockedProps = mock() - -const AddItemsPanelTypeTests: any[] = [ - [KeyTypes.Hash, 'add-hash-field-panel'], - [KeyTypes.ZSet, 'add-zset-field-panel'], - [KeyTypes.Set, 'add-set-field-panel'], - [KeyTypes.List, 'add-list-field-panel'], - [KeyTypes.Stream, 'add-stream-field-panel', StreamViewType.Data], - [KeyTypes.Stream, 'add-stream-groups-field-panel', StreamViewType.Groups], - [KeyTypes.Stream, 'add-stream-groups-field-panel', StreamViewType.Consumers], - [KeyTypes.Stream, 'add-stream-groups-field-panel', StreamViewType.Messages], -] - -describe('AddItemsPanel', () => { - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it.each(AddItemsPanelTypeTests)('for key type: %s (reply), data-subj should exists: %s', - (type: KeyTypes, subj: string, strViewType: StreamViewType = StreamViewType.Data) => { - const { container } = render() - expect(container.querySelector(`[data-test-subj=${subj}]`)).toBeInTheDocument() - }) -}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx deleted file mode 100644 index b6acdda49e..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/AddItemsPanel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react' -import cx from 'classnames' -import { KeyTypes, ModulesKeyTypes, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' -import { StreamViewType } from 'uiSrc/slices/interfaces/stream' -import { - AddHashFields, - AddListElements, - AddSetMembers, - AddStreamEntries, - AddStreamGroup, - AddZsetMembers -} from '../key-details-add-items' - -import styles from './styles.module.scss' - -export interface Props { - selectedKeyType: KeyTypes | ModulesKeyTypes - streamViewType?: StreamViewType - closeAddItemPanel: (isCancelled?: boolean) => void -} - -const AddItemsPanel = (props: Props) => { - const { - selectedKeyType, - streamViewType, - closeAddItemPanel, - } = props - - return ( -
- {selectedKeyType === KeyTypes.Hash && ( - - )} - {selectedKeyType === KeyTypes.ZSet && ( - - )} - {selectedKeyType === KeyTypes.Set && ( - - )} - {selectedKeyType === KeyTypes.List && ( - - )} - {selectedKeyType === KeyTypes.Stream && ( - <> - {streamViewType === StreamViewType.Data && ( - - )} - {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType!) && ( - - )} - - )} -
- ) -} - -export { AddItemsPanel } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts deleted file mode 100644 index f17a51bb6b..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddItemsPanel } from './AddItemsPanel' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss deleted file mode 100644 index 0838dd17dc..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/add-items-panel/styles.module.scss +++ /dev/null @@ -1,4 +0,0 @@ -.contentActive { - border-color: var(--euiColorPrimary) !important; - border-bottom-width: 1px !important; -} \ No newline at end of file diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx index 7cd1e944db..b28d67cc3b 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/HashDetails.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' import { useSelector } from 'react-redux' +import cx from 'classnames' import { selectedKeySelector, @@ -8,8 +9,8 @@ import { KeyTypes } from 'uiSrc/constants' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { HashDetailsTable } from './hash-details-table' - -import { AddItemsPanel } from '../add-items-panel' +import AddHashFields from './add-hash-fields/AddHashFields' +import { AddItemsAction } from '../key-details-actions' export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void @@ -35,13 +36,17 @@ const HashDetails = (props: Props) => { onCloseAddItemPanel() } + const Actions = ({ width }: { width: number }) => ( + + ) + return (
{!loading && ( @@ -50,10 +55,9 @@ const HashDetails = (props: Props) => {
)} {isAddItemPanelOpen && ( - +
+ +
)}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/add-hash-fields/AddHashFields.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/add-hash-fields/AddHashFields.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/add-hash-fields/AddHashFields.tsx similarity index 98% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/add-hash-fields/AddHashFields.tsx index a72654ccfe..ac79648712 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-hash-fields/AddHashFields.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/add-hash-fields/AddHashFields.tsx @@ -140,7 +140,7 @@ const AddHashFields = (props: Props) => { hasShadow={false} borderRadius="none" data-test-subj="add-hash-field-panel" - className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} + className={cx('eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > {fields.map((item, index) => ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.spec.tsx new file mode 100644 index 0000000000..9a94b73691 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, AddItemsAction } from './AddItemsAction' + +const mockedProps = mock() + +describe('AddItemsAction', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.tsx new file mode 100644 index 0000000000..e09bed67e3 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/add-items-action/AddItemsAction.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { EuiButton, EuiButtonIcon, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import { MIDDLE_SCREEN_RESOLUTION } from 'uiSrc/constants' + +import styles from '../styles.module.scss' + +export interface Props { + width: number + title: string + openAddItemPanel: () => void +} + +const AddItemsAction = ({ width, title, openAddItemPanel }: Props) => ( + MIDDLE_SCREEN_RESOLUTION ? '' : title} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {title} + + ) : ( + + )} + + +) + +export { AddItemsAction } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.spec.tsx new file mode 100644 index 0000000000..d50072efe9 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, EditItemAction } from './EditItemAction' + +const mockedProps = mock() + +describe('EditItemAction', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.tsx new file mode 100644 index 0000000000..eb0a0b28a3 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/edit-item-action/EditItemAction.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui' +import { Nullable } from 'uiSrc/utils' + +import styles from '../styles.module.scss' + +export interface Props { + title: string + isEditable: boolean + tooltipContent: Nullable + onEditItem: () => void +} + +const EditItemAction = ({ title, isEditable, tooltipContent, onEditItem }: Props) => ( +
+ + + +
+) + +export { EditItemAction } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/index.ts new file mode 100644 index 0000000000..f32ce84116 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/index.ts @@ -0,0 +1,4 @@ +export { AddItemsAction } from './add-items-action/AddItemsAction' +export { RemoveItemsAction } from './remove-items-action/RemoveItemsAction' +export { EditItemAction } from './edit-item-action/EditItemAction' +export { StreamItemsAction } from './stream-items-action/StreamItemsAction' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.spec.tsx new file mode 100644 index 0000000000..5471e35a48 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, RemoveItemsAction } from './RemoveItemsAction' + +const mockedProps = mock() + +describe('RemoveItemsAction', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.tsx new file mode 100644 index 0000000000..7f4e57f554 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/remove-items-action/RemoveItemsAction.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui' + +import styles from '../styles.module.scss' + +export interface Props { + title: string + openRemoveItemPanel: () => void +} + +const RemoveItemsAction = ({ title, openRemoveItemPanel }: Props) => ( + + + +) + +export { RemoveItemsAction } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.spec.tsx new file mode 100644 index 0000000000..01e1f4cbe4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { Props, StreamItemsAction } from './StreamItemsAction' + +const mockedProps = mock() + +describe('StreamItemsAction', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.tsx new file mode 100644 index 0000000000..070100234b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/stream-items-action/StreamItemsAction.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { EuiButton, EuiButtonIcon, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' + +import { MIDDLE_SCREEN_RESOLUTION } from 'uiSrc/constants' +import styles from '../styles.module.scss' + +export interface Props { + width: number + title: string + openAddItemPanel: () => void +} + +const StreamItemsAction = ({ width, title, openAddItemPanel }: Props) => ( + MIDDLE_SCREEN_RESOLUTION ? '' : title} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {title} + + ) : ( + + )} + + +) + +export { StreamItemsAction } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-actions/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-actions/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts deleted file mode 100644 index 5d97ea6979..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import AddHashFields, { INITIAL_HASH_FIELD_STATE, } from './add-hash-fields/AddHashFields' -import type { IHashFieldState } from './add-hash-fields/AddHashFields' -import AddListElements from './add-list-elements/AddListElements' -import AddSetMembers, { INITIAL_SET_MEMBER_STATE } from './add-set-members/AddSetMembers' -import type { ISetMemberState } from './add-set-members/AddSetMembers' -import AddStreamEntries, { StreamEntryFields } from './add-stream-entity' -import AddStreamGroup from './add-stream-group' -import AddZsetMembers, { INITIAL_ZSET_MEMBER_STATE } from './add-zset-members/AddZsetMembers' -import type { IZsetMemberState } from './add-zset-members/AddZsetMembers' - -export { - AddHashFields, - AddListElements, - AddSetMembers, - AddStreamEntries, - StreamEntryFields, - AddZsetMembers, - AddStreamGroup, - INITIAL_HASH_FIELD_STATE, - INITIAL_SET_MEMBER_STATE, - INITIAL_ZSET_MEMBER_STATE, - IHashFieldState, - ISetMemberState, - IZsetMemberState, -} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/styles.module.scss deleted file mode 100644 index aa5b89a30c..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/styles.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.content { - display: flex; - flex-direction: column; - max-height: 184px; - width: 100%; - border: none !important; - border-top: 1px solid var(--euiColorPrimary); - padding: 12px 20px; - scroll-padding-bottom: 72px; -} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts deleted file mode 100644 index 5f5c9ba2f4..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RemoveListElements } from './remove-list-elements/RemoveListElements' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx index 817694e0fd..3fc30fd283 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/ListDetails.tsx @@ -10,9 +10,10 @@ import { KeyTypes } from 'uiSrc/constants' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { ListDetailsTable } from './list-details-table' -import { AddItemsPanel } from '../add-items-panel' -import { RemoveListElements } from '../key-details-remove-items' +import { RemoveListElements } from './remove-list-elements' +import AddListElements from './add-list-elements/AddListElements' +import { AddItemsAction, RemoveItemsAction } from '../key-details-actions' import styles from './styles.module.scss' export interface Props extends KeyDetailsHeaderProps { @@ -49,14 +50,20 @@ const ListDetails = (props: Props) => { setIsRemoveItemPanelOpen(true) } + const Actions = ({ width }: { width: number }) => ( + <> + + + + ) + return (
{!loading && ( @@ -65,16 +72,13 @@ const ListDetails = (props: Props) => {
)} {isAddItemPanelOpen && ( - +
+ +
)} {isRemoveItemPanelOpen && (
- {keyType === KeyTypes.List && ( - - )} +
)}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/add-list-elements/AddListElements.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/add-list-elements/AddListElements.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/add-list-elements/AddListElements.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/add-list-elements/AddListElements.tsx index 1eea050ad3..9adfa7a564 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-list-elements/AddListElements.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/add-list-elements/AddListElements.tsx @@ -19,10 +19,9 @@ import { insertListElementsAction } from 'uiSrc/slices/browser/list' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { KeyTypes } from 'uiSrc/constants' import { stringToBuffer } from 'uiSrc/utils' +import { AddListFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' import { PushElementToListDto } from 'apiSrc/modules/browser/dto' -import { AddListFormConfig as config } from '../../../../../components/add-key/constants/fields-config' - import styles from '../styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/RemoveListElements.spec.tsx similarity index 96% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/RemoveListElements.spec.tsx index e9ed669459..ac78e7af6a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/RemoveListElements.spec.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' import { Props, RemoveListElements } from './RemoveListElements' -import { HEAD_DESTINATION } from '../../key-details-add-items/add-list-elements/AddListElements' +import { HEAD_DESTINATION } from '../add-list-elements/AddListElements' const COUNT_INPUT = 'count-input' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/RemoveListElements.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/RemoveListElements.tsx index 324bad203a..7b9b71a289 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/RemoveListElements.tsx @@ -28,16 +28,16 @@ import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys import { deleteListElementsAction } from 'uiSrc/slices/browser/list' import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { AddListFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' import { DeleteListElementsDto } from 'apiSrc/modules/browser/dto' -import { AddListFormConfig as config } from '../../../../../components/add-key/constants/fields-config' import { TAIL_DESTINATION, HEAD_DESTINATION, ListElementDestination, -} from '../../key-details-add-items/add-list-elements/AddListElements' +} from '../add-list-elements/AddListElements' -import styles from '../styles.module.scss' +import styles from './styles.module.scss' export interface Props { onCancel: () => void diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/index.ts new file mode 100644 index 0000000000..86e8b36cd7 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/index.ts @@ -0,0 +1 @@ +export { RemoveListElements } from './RemoveListElements' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-remove-items/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/list-details/remove-list-elements/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx index 3aad145c8d..8ef388c9c5 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/SetDetails.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' import { useSelector } from 'react-redux' +import cx from 'classnames' import { selectedKeySelector, @@ -8,8 +9,8 @@ import { KeyTypes } from 'uiSrc/constants' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { SetDetailsTable } from './set-details-table' - -import { AddItemsPanel } from '../add-items-panel' +import { AddSetMembers } from './add-set-members' +import { AddItemsAction } from '../key-details-actions' export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void @@ -35,13 +36,17 @@ const SetDetails = (props: Props) => { onCloseAddItemPanel() } + const Actions = ({ width }: { width: number }) => ( + + ) + return (
{!loading && ( @@ -50,10 +55,9 @@ const SetDetails = (props: Props) => {
)} {isAddItemPanelOpen && ( - +
+ +
)}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers.spec.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers.spec.tsx index 53202495b6..49441d1f91 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import AddSetMembers, { Props } from './AddSetMembers' +import { AddSetMembers, Props } from './AddSetMembers' const MEMBER_NAME = 'member-name' const ADD_NEW_ITEM = 'add-new-item' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers.tsx similarity index 94% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers.tsx index 0c0f65b551..1fa3141644 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-set-members/AddSetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/AddSetMembers.tsx @@ -18,10 +18,8 @@ import { KeyTypes } from 'uiSrc/constants' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { stringToBuffer } from 'uiSrc/utils' -import AddItemsActions from '../../../../../components/add-items-actions/AddItemsActions' -import { AddZsetFormConfig as config } from '../../../../../components/add-key/constants/fields-config' - -import styles from '../styles.module.scss' +import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' +import { AddZsetFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' export interface Props { onCancel: (isCancelled?: boolean) => void; @@ -125,7 +123,7 @@ const AddSetMembers = (props: Props) => { hasShadow={false} borderRadius="none" data-test-subj="add-set-field-panel" - className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth')} + className={cx('eui-yScroll', 'flexItemNoFullWidth')} > {members.map((item, index) => ( @@ -196,4 +194,4 @@ const AddSetMembers = (props: Props) => { ) } -export default AddSetMembers +export { AddSetMembers } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/index.ts new file mode 100644 index 0000000000..0d82389552 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/set-details/add-set-members/index.ts @@ -0,0 +1 @@ +export { AddSetMembers } from './AddSetMembers' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx index 4408fd19d1..25de1bf3d4 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.spec.tsx @@ -1,12 +1,56 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' +import { fireEvent, render } from 'uiSrc/utils/test-utils' +import { streamSelector } from 'uiSrc/slices/browser/stream' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { Props, StreamDetails } from './StreamDetails' const mockedProps = mock() +jest.mock('uiSrc/slices/browser/stream', () => ({ + ...jest.requireActual('uiSrc/slices/browser/stream'), + streamSelector: jest.fn().mockReturnValue({ + viewType: 'Data', + }), +})) + describe('StreamDetails', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it('"add-key-value-items-btn" should render', () => { + const { queryByTestId } = render() + expect(queryByTestId('add-key-value-items-btn')).toBeInTheDocument() + }) + + it('"add-stream-field-panel" should render', () => { + const { container, queryByTestId } = render( + {}} + /> + ) + + fireEvent.click(queryByTestId('add-key-value-items-btn')!) + expect(container.querySelector('[data-test-subj="add-stream-field-panel"]')).toBeInTheDocument() + expect(container.querySelector('[data-test-subj="add-stream-groups-field-panel"]')).not.toBeInTheDocument() + }) + it('"add-stream-groups-field-panel" should render', () => { + const streamSelectorMock = jest.fn().mockReturnValue({ + viewType: StreamViewType.Groups, + }); + (streamSelector as jest.Mock).mockImplementation(streamSelectorMock) + + const { container, queryByTestId } = render( + {}} + /> + ) + + fireEvent.click(queryByTestId('add-key-value-items-btn')!) + expect(container.querySelector('[data-test-subj="add-stream-field-panel"]')).not.toBeInTheDocument() + expect(container.querySelector('[data-test-subj="add-stream-groups-field-panel"]')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx index 69d5d049ae..53c71be024 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/StreamDetails.tsx @@ -1,16 +1,19 @@ import React, { useState } from 'react' import { useSelector } from 'react-redux' +import cx from 'classnames' import { selectedKeySelector, } from 'uiSrc/slices/browser/keys' -import { KeyTypes, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' +import { KeyTypes, STREAM_ADD_ACTION, STREAM_ADD_GROUP_VIEW_TYPES } from 'uiSrc/constants' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { streamSelector } from 'uiSrc/slices/browser/stream' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { StreamDetailsBody } from './stream-details-body' - -import { AddItemsPanel } from '../add-items-panel' +import AddStreamEntries from './add-stream-entity' +import AddStreamGroup from './add-stream-group' +import { StreamItemsAction } from '../key-details-actions' export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void @@ -42,13 +45,21 @@ const StreamDetails = (props: Props) => { } } + const Actions = ({ width }: { width: number }) => ( + + ) + return (
{!loading && ( @@ -57,11 +68,14 @@ const StreamDetails = (props: Props) => {
)} {isAddItemPanelOpen && ( - +
+ {streamViewType === StreamViewType.Data && ( + + )} + {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType!) && ( + + )} +
)}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/AddStreamEntries.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/AddStreamEntries.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/AddStreamEntries.tsx similarity index 97% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/AddStreamEntries.tsx index 5b0690a639..8b87d26c52 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/AddStreamEntries.tsx @@ -9,11 +9,11 @@ import { addNewEntriesAction, streamDataSelector } from 'uiSrc/slices/browser/st import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { AddStreamFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' import { INITIAL_STREAM_FIELD_STATE } from 'uiSrc/pages/browser/components/add-key/AddKeyStream/AddKeyStream' -import { StreamEntryFields } from 'uiSrc/pages/browser/modules/key-details/components/key-details-add-items' import { KeyTypes } from 'uiSrc/constants' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { AddStreamEntriesDto } from 'apiSrc/modules/browser/dto/stream.dto' +import StreamEntryFields from './StreamEntryFields/StreamEntryFields' import styles from './styles.module.scss' export interface Props { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/StreamEntryFields/StreamEntryFields.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/index.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-entity/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-entity/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/AddStreamGroup.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/AddStreamGroup.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/AddStreamGroup.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/AddStreamGroup.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/index.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-stream-group/styles.module.scss rename to redisinsight/ui/src/pages/browser/modules/key-details/components/stream-details/add-stream-group/styles.module.scss diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx index 8e61a909d5..dbe2cbaebf 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx @@ -1,12 +1,71 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' +import { render, screen } from 'uiSrc/utils/test-utils' +import { stringDataSelector } from 'uiSrc/slices/browser/string' import { Props, StringDetails } from './StringDetails' const mockedProps = mock() +const EDIT_VALUE_BTN_TEST_ID = 'edit-key-value-btn' + +jest.mock('uiSrc/slices/browser/string', () => ({ + ...jest.requireActual('uiSrc/slices/browser/string'), + stringDataSelector: jest.fn().mockReturnValue({ + value: { + type: 'Buffer', + data: [49, 50, 51, 52], + } + }), +})) + +jest.mock('uiSrc/slices/browser/keys', () => ({ + ...jest.requireActual('uiSrc/slices/browser/keys'), + selectedKeyDataSelector: jest.fn().mockReturnValue({ + name: { + type: 'Buffer', + data: [116, 101, 115, 116] + }, + nameString: 'test', + length: 4 + }), +})) describe('StringDetails', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it('should be able to change value (long string fully load)', () => { + render( + + ) + + const editValueBtn = screen.getByTestId(`${EDIT_VALUE_BTN_TEST_ID}`) + expect(editValueBtn).toHaveProperty('disabled', false) + }) + + it('should not be able to change value (long string not fully load)', () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: { + type: 'Buffer', + data: [49, 50, 51], + } + }) + stringDataSelector.mockImplementation(stringDataSelectorMock) + + render( + + ) + + const editValueBtn = screen.getByTestId(`${EDIT_VALUE_BTN_TEST_ID}`) + expect(editValueBtn).toHaveProperty('disabled', true) + }) + + it('"edit-key-value-btn" should render', () => { + const { queryByTestId } = render() + expect(queryByTestId('edit-key-value-btn')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx index dbc5ee9f1f..1bccc258b3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx @@ -2,22 +2,42 @@ import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { + initialKeyInfo, refreshKey, + selectedKeyDataSelector, selectedKeySelector, } from 'uiSrc/slices/browser/keys' -import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import { + KeyTypes, + ModulesKeyTypes, + TEXT_DISABLED_COMPRESSED_VALUE, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_DISABLED_STRING_EDITING, +} from 'uiSrc/constants' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' +import { stringDataSelector, stringSelector } from 'uiSrc/slices/browser/string' +import { isFormatEditable, isFullStringLoaded } from 'uiSrc/utils' import { StringDetailsTable } from './string-details-table' +import { EditItemAction } from '../key-details-actions' export interface Props extends KeyDetailsHeaderProps {} const StringDetails = (props: Props) => { const keyType = KeyTypes.String - const { loading } = useSelector(selectedKeySelector) + const { loading, viewFormat: viewFormatProp } = useSelector(selectedKeySelector) + const { length } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo + const { value: keyValue } = useSelector(stringDataSelector) + const { isCompressed: isStringCompressed } = useSelector(stringSelector) + + const isEditable = !isStringCompressed && isFormatEditable(viewFormatProp) + const isStringEditable = isFullStringLoaded(keyValue?.data?.length, length) + const noEditableText = isStringCompressed ? TEXT_DISABLED_COMPRESSED_VALUE : TEXT_DISABLED_FORMATTER_EDITING + const editToolTip = !isEditable ? noEditableText : (!isStringEditable ? TEXT_DISABLED_STRING_EDITING : null) + const [editItem, setEditItem] = useState(false) const dispatch = useDispatch() @@ -26,13 +46,22 @@ const StringDetails = (props: Props) => { dispatch(refreshKey(key, type, args)) } + const Actions = () => ( + setEditItem(!editItem)} + /> + ) + return (
setEditItem(!editItem)} + Actions={Actions} />
{!loading && ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx index f6a1760f18..80392733fa 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/ZSetDetails.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' import { useSelector } from 'react-redux' +import cx from 'classnames' import { selectedKeySelector, @@ -8,8 +9,8 @@ import { KeyTypes } from 'uiSrc/constants' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { ZSetDetailsTable } from './zset-details-table' - -import { AddItemsPanel } from '../add-items-panel' +import AddZsetMembers from './add-zset-members/AddZsetMembers' +import { AddItemsAction } from '../key-details-actions' export interface Props extends KeyDetailsHeaderProps { onRemoveKey: () => void @@ -35,13 +36,17 @@ const ZSetDetails = (props: Props) => { onCloseAddItemPanel() } + const Actions = ({ width }: { width: number }) => ( + + ) + return (
{!loading && ( @@ -50,10 +55,9 @@ const ZSetDetails = (props: Props) => {
)} {isAddItemPanelOpen && ( - +
+ +
)}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/add-zset-members/AddZsetMembers.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/add-zset-members/AddZsetMembers.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/add-zset-members/AddZsetMembers.tsx similarity index 95% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/add-zset-members/AddZsetMembers.tsx index f4eb53ce66..3be632d355 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/zset-details/add-zset-members/AddZsetMembers.tsx @@ -21,10 +21,8 @@ import { updateZsetScoreStateSelector, } from 'uiSrc/slices/browser/zset' -import AddItemsActions from '../../../../../components/add-items-actions/AddItemsActions' -import { AddZsetFormConfig as config } from '../../../../../components/add-key/constants/fields-config' - -import styles from '../styles.module.scss' +import { AddZsetFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' export interface Props { onCancel: (isCancelled?: boolean) => void; @@ -174,7 +172,7 @@ const AddZsetMembers = (props: Props) => { hasShadow={false} borderRadius="none" data-test-subj="add-zset-field-panel" - className={cx(styles.content, 'eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} + className={cx('eui-yScroll', 'flexItemNoFullWidth', 'inlineFieldsNoSpace')} > {members.map((item, index) => ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss index 6f939d4cfb..e7eea19e00 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/modules/key-details/styles.module.scss @@ -45,3 +45,15 @@ border-top: 1px solid var(--euiColorLightShade); } +.actionBtn { + margin-right: 12px; + position: relative; + z-index: 2; + + &.withText { + color: var(--euiTextSubduedColor) !important; + :global(.euiButton__text) { + font: normal normal normal 12px/18px Graphik !important; + } + } +} diff --git a/redisinsight/ui/src/styles/components/_forms.scss b/redisinsight/ui/src/styles/components/_forms.scss index df208b6296..1a15b15e31 100644 --- a/redisinsight/ui/src/styles/components/_forms.scss +++ b/redisinsight/ui/src/styles/components/_forms.scss @@ -69,6 +69,11 @@ max-height: 100%; @include euiScrollBar; overflow-y: auto; + + &.contentActive { + border-color: var(--euiColorPrimary) !important; + border-bottom-width: 1px !important; + } } .euiRadio .euiRadio__circle, From 987cdde0eb30f2059b480e39a7ee5d1a22c37656 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 14 Nov 2023 17:02:16 +0100 Subject: [PATCH 46/96] #RI-5033 - fix pr comment --- .../modules/key-details-header/KeyDetailsHeader.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 0894603cc2..633a61c804 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -6,6 +6,7 @@ import { EuiToolTip, } from '@elastic/eui' import React, { ReactElement } from 'react' +import { isUndefined } from 'lodash' import { useDispatch, useSelector } from 'react-redux' import AutoSizer from 'react-virtualized-auto-sizer' @@ -47,7 +48,7 @@ export interface KeyDetailsHeaderProps { isFullScreen: boolean arePanelsCollapsed: boolean onToggleFullScreen: () => void - Actions: (props: { width: number }) => Nullable + Actions?: (props: { width: number }) => ReactElement } const KeyDetailsHeader = ({ @@ -58,7 +59,7 @@ const KeyDetailsHeader = ({ onRemoveKey, onEditKey, keyType, - Actions = () => null, + Actions, }: KeyDetailsHeaderProps) => { const { loading, lastRefreshTime } = useSelector(selectedKeySelector) const { @@ -186,7 +187,7 @@ const KeyDetailsHeader = ({ {Object.values(KeyTypes).includes(keyType as KeyTypes) && ( )} - {keyType && } + {!isUndefined(Actions) && }
From 64a1ab09cf05d13d3b654012ed0f6df3bd6539e9 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 14 Nov 2023 17:05:31 +0100 Subject: [PATCH 47/96] #RI-5033 - fix pr comment --- .../modules/key-details-header/KeyDetailsHeader.tsx | 10 ++-------- .../KeyDetailsHeaderDelete.tsx | 4 ++-- .../components/string-details/StringDetails.tsx | 9 ++++++++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 633a61c804..68e7a62130 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -84,14 +84,8 @@ const KeyDetailsHeader = ({ dispatch(editKey(oldKey, newKey, () => onEditKey(oldKey, newKey), onFailure)) } - const handleDeleteKey = (key: RedisResponseBuffer, type: string) => { - dispatch(deleteSelectedKeyAction(key, - () => { - if (type === KeyTypes.String) { - dispatch(resetStringValue()) - } - onRemoveKey() - })) + const handleDeleteKey = (key: RedisResponseBuffer) => { + dispatch(deleteSelectedKeyAction(key, onRemoveKey)) } const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx index 0494e0a6ed..b052b788d8 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-delete/KeyDetailsHeaderDelete.tsx @@ -18,7 +18,7 @@ import { import styles from './styles.module.scss' export interface Props { - onDelete: (key: RedisResponseBuffer, type: string) => void + onDelete: (key: RedisResponseBuffer) => void } const KeyDetailsHeaderDelete = ({ onDelete }: Props) => { @@ -88,7 +88,7 @@ const KeyDetailsHeaderDelete = ({ onDelete }: Props) => { size="s" color="warning" iconType="trash" - onClick={() => onDelete(keyBuffer, type)} + onClick={() => onDelete(keyBuffer)} className={styles.popoverDeleteBtn} data-testid="delete-key-confirm-btn" > diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx index 1bccc258b3..bea335540b 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx @@ -18,7 +18,7 @@ import { import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' -import { stringDataSelector, stringSelector } from 'uiSrc/slices/browser/string' +import { resetStringValue, stringDataSelector, stringSelector } from 'uiSrc/slices/browser/string' import { isFormatEditable, isFullStringLoaded } from 'uiSrc/utils' import { StringDetailsTable } from './string-details-table' import { EditItemAction } from '../key-details-actions' @@ -26,6 +26,7 @@ import { EditItemAction } from '../key-details-actions' export interface Props extends KeyDetailsHeaderProps {} const StringDetails = (props: Props) => { + const { onRemoveKey } = props const keyType = KeyTypes.String const { loading, viewFormat: viewFormatProp } = useSelector(selectedKeySelector) @@ -46,6 +47,11 @@ const StringDetails = (props: Props) => { dispatch(refreshKey(key, type, args)) } + const handleRemoveKey = () => { + dispatch(resetStringValue()) + onRemoveKey() + } + const Actions = () => ( { {...props} key="key-details-header" keyType={keyType} + onRemoveKey={handleRemoveKey} Actions={Actions} />
From 17b95153ce6999935949c6de90e77ceb930911f5 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 16 Nov 2023 09:46:54 +0100 Subject: [PATCH 48/96] fix yarn issue 1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39e2e5bb9f..6d1dd39521 100644 --- a/package.json +++ b/package.json @@ -295,7 +295,7 @@ "devEngines": { "node": ">=16.x", "npm": ">=6.x", - "yarn": ">=1.21.3" + "yarn": ">=1.22.21" }, "husky": { "hooks": {} From 10df7b46ff597a511021a59dfd5871cc99e1f7f2 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 16 Nov 2023 13:04:55 +0400 Subject: [PATCH 49/96] #RI-5131-5170 - update tree view --- redisinsight/ui/src/pages/browser/BrowserPage.tsx | 2 +- .../ui/src/pages/browser/components/key-tree/KeyTree.tsx | 2 +- .../src/pages/browser/components/virtual-tree/VirtualTree.tsx | 4 ++++ .../browser/components/virtual-tree/components/Node/Node.tsx | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index a05f8097f5..f87e868291 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -190,7 +190,7 @@ const BrowserPage = () => { } const selectKey = ({ rowData }: { rowData: any }) => { - if (!isEqualBuffers(rowData.name, selectedKey)) { + if (!isEqualBuffers(rowData.name, selectedKeyRef.current)) { dispatch(toggleBrowserFullScreen(false)) dispatch(setInitialStateByType(prevSelectedType.current)) diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx index 9e9d2d10d7..76a1552393 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx @@ -126,7 +126,7 @@ const KeyTree = forwardRef((props: Props, ref) => { openSelectedKey(selectedKeyName) } - const handleStatusOpen = (name: string, value:boolean) => { + const handleStatusOpen = (name: string, value: boolean) => { setStatusOpen((prevState) => { const newState = { ...prevState } // add or remove opened node diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index 552873d9b2..a0d073b977 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -94,6 +94,10 @@ const VirtualTree = (props: Props) => { nodes.current = result rerender({}) setConstructingTree?.(false) + + if (nodes?.current?.length === 1) { + onStatusOpen?.(nodes.current[0].fullName, true) + } }, [result]) useEffect(() => { diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx index 8739c9c7a8..1bc3f02205 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx @@ -66,7 +66,7 @@ const Node = ({ }, []) const handleClick = () => { - if (isLeaf && !isSelected) { + if (isLeaf) { updateStatusSelected?.(nameBuffer) } From 78ba705c16f45a62941e4f1341d0379c72be5928 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 16 Nov 2023 13:34:30 +0400 Subject: [PATCH 50/96] #RI-5131 - add tests --- .../virtual-tree/VirtualTree.spec.tsx | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index b869123dd7..79443ca613 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -2,6 +2,7 @@ import React from 'react' import { mock, instance } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' +import { useDisposableWebworker } from 'uiSrc/services' import VirtualTree, { Props } from './VirtualTree' const mockedProps = mock() @@ -39,9 +40,26 @@ export const mockVirtualTreeResult = [{ name: 'test', }] +export const mockVirtualTreeResult1 = [{ + children: [{ + children: [], + fullName: 'car:110:', + id: '0.snc1rc3zwgo', + keyApproximate: 0.01, + keyCount: 1, + name: '110', + }], + fullName: 'car:', + id: '0.sz1ie1koqi8', + keyApproximate: 47.18, + keyCount: 4718, + name: 'car', +}] + jest.mock('uiSrc/services', () => ({ ...jest.requireActual('uiSrc/services'), - useDisposableWebworker: () => ({ result: mockVirtualTreeResult, run: jest.fn() }), + useDisposableWebworker: jest.fn(() => ({ result: mockVirtualTreeResult, run: jest.fn() })) + // useDisposableWebworker: () => ({ result: mockVirtualTreeResult, run: jest.fn() }), })) describe('VirtualTree', () => { @@ -74,4 +92,38 @@ describe('VirtualTree', () => { expect(queryByTestId('node-item_test')).toBeInTheDocument() }) + + it('should call onStatusOpen if only one folder is exist', () => { + (useDisposableWebworker as jest.Mock).mockReturnValueOnce({ + result: mockVirtualTreeResult1, + run: jest.fn() + }) + const mockFn = jest.fn() + const mockOnStatusOpen = jest.fn() + + render( + + ) + + expect(mockOnStatusOpen).toBeCalledWith('car:', true) + }) + + it('should not call onStatusOpen if more then one folder is exist', () => { + const mockFn = jest.fn() + const mockOnStatusOpen = jest.fn() + + render( + + ) + + expect(mockOnStatusOpen).not.toHaveBeenCalled() + }) }) From ca51be757cf8df7cfce4c30973a117098dd02dae Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 16 Nov 2023 13:35:20 +0400 Subject: [PATCH 51/96] #RI-5131 - remove unused code --- .../pages/browser/components/virtual-tree/VirtualTree.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index 79443ca613..90fe19ce50 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -59,7 +59,6 @@ export const mockVirtualTreeResult1 = [{ jest.mock('uiSrc/services', () => ({ ...jest.requireActual('uiSrc/services'), useDisposableWebworker: jest.fn(() => ({ result: mockVirtualTreeResult, run: jest.fn() })) - // useDisposableWebworker: () => ({ result: mockVirtualTreeResult, run: jest.fn() }), })) describe('VirtualTree', () => { From aa10370941e05e4ab4f386218254b61914bb5255 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 16 Nov 2023 10:48:38 +0100 Subject: [PATCH 52/96] yarn fix 2 --- package.json | 2 +- tests/e2e/docker.web.docker-compose.yml | 2 +- tests/e2e/local.web.docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6d1dd39521..39e2e5bb9f 100644 --- a/package.json +++ b/package.json @@ -295,7 +295,7 @@ "devEngines": { "node": ">=16.x", "npm": ">=6.x", - "yarn": ">=1.22.21" + "yarn": ">=1.21.3" }, "husky": { "hooks": {} diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 99d2099406..2327d1b260 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -33,7 +33,7 @@ services: command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', - 'npx', 'yarn', 'test:chrome:ci' + 'npx', 'run', 'test:chrome:ci' ] # Built image diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index 554821fdc9..c4c13d9cdb 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -33,7 +33,7 @@ services: command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', - 'npx', 'yarn', 'test:chrome:ci' + 'npx', 'run', 'test:chrome:ci' ] # Redisinsight API + UI build From 0a96939bb39a6b6f7510d790236b78a95013772f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 16 Nov 2023 10:56:28 +0100 Subject: [PATCH 53/96] fix yarn 3 --- tests/e2e/docker.web.docker-compose.yml | 2 +- tests/e2e/local.web.docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 2327d1b260..9cd09a2e2f 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -33,7 +33,7 @@ services: command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', - 'npx', 'run', 'test:chrome:ci' + 'npm', 'run', 'test:chrome:ci' ] # Built image diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index c4c13d9cdb..361f8a0acd 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -33,7 +33,7 @@ services: command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', - 'npx', 'run', 'test:chrome:ci' + 'npm', 'run', 'test:chrome:ci' ] # Redisinsight API + UI build From c5c8633eb2ead0cec0f56ce7d36760c55015dbf8 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 16 Nov 2023 15:06:03 +0400 Subject: [PATCH 54/96] #RI-5131 - fix test --- .../virtual-tree/VirtualTree.spec.tsx | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index 90fe19ce50..5610b52c84 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -16,7 +16,7 @@ const mockedItems = [ }, ] -export const mockVirtualTreeResult = [{ +const mockTreeItem = { children: [{ children: [], fullName: 'car:110:', @@ -30,34 +30,22 @@ export const mockVirtualTreeResult = [{ keyApproximate: 47.18, keyCount: 4718, name: 'car', -}, -{ +} + +const mockTreeItem2 = { children: [], fullName: 'test', id: '0.snc1rc3zwg1o', keyApproximate: 0.01, keyCount: 1, name: 'test', -}] +} -export const mockVirtualTreeResult1 = [{ - children: [{ - children: [], - fullName: 'car:110:', - id: '0.snc1rc3zwgo', - keyApproximate: 0.01, - keyCount: 1, - name: '110', - }], - fullName: 'car:', - id: '0.sz1ie1koqi8', - keyApproximate: 47.18, - keyCount: 4718, - name: 'car', -}] +export const mockVirtualTreeResult = [mockTreeItem, mockTreeItem2] jest.mock('uiSrc/services', () => ({ ...jest.requireActual('uiSrc/services'), + __esModule: true, useDisposableWebworker: jest.fn(() => ({ result: mockVirtualTreeResult, run: jest.fn() })) })) @@ -94,7 +82,7 @@ describe('VirtualTree', () => { it('should call onStatusOpen if only one folder is exist', () => { (useDisposableWebworker as jest.Mock).mockReturnValueOnce({ - result: mockVirtualTreeResult1, + result: [mockTreeItem], run: jest.fn() }) const mockFn = jest.fn() From 9a3ac4a52cf24ec18fbab866d34cda3be54734c6 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 16 Nov 2023 15:47:57 +0400 Subject: [PATCH 55/96] #RI-5131 - fix circleci test --- .../browser/components/virtual-tree/VirtualTree.spec.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index 5610b52c84..9726ccb8a3 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -45,8 +45,7 @@ export const mockVirtualTreeResult = [mockTreeItem, mockTreeItem2] jest.mock('uiSrc/services', () => ({ ...jest.requireActual('uiSrc/services'), - __esModule: true, - useDisposableWebworker: jest.fn(() => ({ result: mockVirtualTreeResult, run: jest.fn() })) + useDisposableWebworker: jest.fn().mockReturnValue({ result: mockVirtualTreeResult, run: jest.fn() }) })) describe('VirtualTree', () => { @@ -68,6 +67,10 @@ describe('VirtualTree', () => { }) it('should render items', async () => { + (useDisposableWebworker as jest.Mock).mockReturnValueOnce({ + result: mockVirtualTreeResult, + run: jest.fn() + }) const mockFn = jest.fn() const { queryByTestId } = render( { expect(mockOnStatusOpen).toBeCalledWith('car:', true) }) - it('should not call onStatusOpen if more then one folder is exist', () => { + it('should not call onStatusOpen if more than one folder is exist', () => { const mockFn = jest.fn() const mockOnStatusOpen = jest.fn() From f945b8d0d70b8dfc08f179c39bf5d0e670090eca Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 16 Nov 2023 16:27:52 +0400 Subject: [PATCH 56/96] #RI-5131 - fix test --- .../virtual-tree/VirtualTree.spec.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index 9726ccb8a3..9c169fda9a 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -2,7 +2,7 @@ import React from 'react' import { mock, instance } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' -import { useDisposableWebworker } from 'uiSrc/services' +import * as Services from 'uiSrc/services' import VirtualTree, { Props } from './VirtualTree' const mockedProps = mock() @@ -44,6 +44,7 @@ const mockTreeItem2 = { export const mockVirtualTreeResult = [mockTreeItem, mockTreeItem2] jest.mock('uiSrc/services', () => ({ + __esModule: true, ...jest.requireActual('uiSrc/services'), useDisposableWebworker: jest.fn().mockReturnValue({ result: mockVirtualTreeResult, run: jest.fn() }) })) @@ -67,10 +68,9 @@ describe('VirtualTree', () => { }) it('should render items', async () => { - (useDisposableWebworker as jest.Mock).mockReturnValueOnce({ - result: mockVirtualTreeResult, - run: jest.fn() - }) + jest.spyOn(Services, 'useDisposableWebworker') + .mockReturnValueOnce({ result: [mockTreeItem, mockTreeItem2], run: jest.fn() }) + const mockFn = jest.fn() const { queryByTestId } = render( { }) it('should call onStatusOpen if only one folder is exist', () => { - (useDisposableWebworker as jest.Mock).mockReturnValueOnce({ - result: [mockTreeItem], - run: jest.fn() - }) + jest.spyOn(Services, 'useDisposableWebworker') + .mockReturnValueOnce({ result: [mockTreeItem], run: jest.fn() }) + const mockFn = jest.fn() const mockOnStatusOpen = jest.fn() From f01d5bb36fbda920dd668dd52806fbf2180fbf00 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 17 Nov 2023 16:20:30 +0400 Subject: [PATCH 57/96] #RI-5125 - add autodicovery utm param --- redisinsight/ui/src/slices/interfaces/cloud.ts | 1 + redisinsight/ui/src/utils/oauth/cloudSsoUtm.tsx | 2 ++ redisinsight/ui/src/utils/tests/oauth/cloudSsoUtm.spec.tsx | 1 + 3 files changed, 4 insertions(+) diff --git a/redisinsight/ui/src/slices/interfaces/cloud.ts b/redisinsight/ui/src/slices/interfaces/cloud.ts index e13553a4f5..c1b4a4fce7 100644 --- a/redisinsight/ui/src/slices/interfaces/cloud.ts +++ b/redisinsight/ui/src/slices/interfaces/cloud.ts @@ -84,5 +84,6 @@ export enum CloudSsoUtmCampaign { BrowserFilter = 'browser_filter', Tutorial = 'tutorial', TriggersAndFunctions = 'redisinsight_triggers_and_functions', + AutoDiscovery = 'auto_discovery', Unknown = 'other', } diff --git a/redisinsight/ui/src/utils/oauth/cloudSsoUtm.tsx b/redisinsight/ui/src/utils/oauth/cloudSsoUtm.tsx index 5861947fa8..a0b2a5b6bd 100644 --- a/redisinsight/ui/src/utils/oauth/cloudSsoUtm.tsx +++ b/redisinsight/ui/src/utils/oauth/cloudSsoUtm.tsx @@ -24,6 +24,8 @@ export const getCloudSsoUtmCampaign = (source?: string | null): CloudSsoUtmCampa return CloudSsoUtmCampaign.TriggersAndFunctions case OAuthSocialSource.Tutorials: return CloudSsoUtmCampaign.Tutorial + case OAuthSocialSource.Autodiscovery: + return CloudSsoUtmCampaign.AutoDiscovery default: return CloudSsoUtmCampaign.Unknown } diff --git a/redisinsight/ui/src/utils/tests/oauth/cloudSsoUtm.spec.tsx b/redisinsight/ui/src/utils/tests/oauth/cloudSsoUtm.spec.tsx index 4678c0fe38..c231f1ef4c 100644 --- a/redisinsight/ui/src/utils/tests/oauth/cloudSsoUtm.spec.tsx +++ b/redisinsight/ui/src/utils/tests/oauth/cloudSsoUtm.spec.tsx @@ -15,6 +15,7 @@ const getCloudSsoUtmCampaignTestCases = [ [OAuthSocialSource.WelcomeScreen, CloudSsoUtmCampaign.WelcomeScreen], [OAuthSocialSource.TriggersAndFunctions, CloudSsoUtmCampaign.TriggersAndFunctions], [OAuthSocialSource.Tutorials, CloudSsoUtmCampaign.Tutorial], + [OAuthSocialSource.Autodiscovery, CloudSsoUtmCampaign.AutoDiscovery], [null, CloudSsoUtmCampaign.Unknown], [undefined, CloudSsoUtmCampaign.Unknown], ] From 7f369f2d385f79405d1cb11f9d703b3887128fb7 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Fri, 17 Nov 2023 13:32:25 +0100 Subject: [PATCH 58/96] #RI-5162 - fix page re-render only when located on the same page --- .../ui/src/pages/instance/InstancePage.tsx | 24 +++++++++++++++---- redisinsight/ui/src/slices/oauth/cloud.ts | 4 ---- redisinsight/ui/src/utils/routing.ts | 2 ++ .../ui/src/utils/tests/routing.spec.ts | 19 ++++++++++++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index fd09dc9776..6dda525855 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -1,7 +1,7 @@ import { EuiResizableContainer } from '@elastic/eui' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useParams } from 'react-router-dom' +import { useLocation, useParams } from 'react-router-dom' import cx from 'classnames' import { setInitialAnalyticsSettings } from 'uiSrc/slices/analytics/settings' @@ -34,6 +34,7 @@ import { setClusterDetailsInitialState } from 'uiSrc/slices/analytics/clusterDet import { setDatabaseAnalysisInitialState } from 'uiSrc/slices/analytics/dbAnalysis' import { resetRedisearchKeysData, setRedisearchInitialState } from 'uiSrc/slices/browser/redisearch' import { setTriggeredFunctionsInitialState } from 'uiSrc/slices/triggeredFunctions/triggeredFunctions' +import { getPageName } from 'uiSrc/utils/routing' import InstancePageRouter from './InstancePageRouter' import styles from './styles.module.scss' @@ -66,12 +67,16 @@ const InstancePage = ({ routes = [] }: Props) => { const [isShouldChildrenRerender, setIsShouldChildrenRerender] = useState(false) const dispatch = useDispatch() + const { pathname } = useLocation() + const { instanceId: connectionInstanceId } = useParams<{ instanceId: string }>() const { isShowCli, isShowHelper } = useSelector(cliSettingsSelector) const { data: modulesData } = useSelector(instancesSelector) const { isShowMonitor } = useSelector(monitorSelector) const { contextInstanceId } = useSelector(appContextSelector) + const lastPageRef = useRef() + const isShowBottomGroup = isShowCli || isShowHelper || isShowMonitor useEffect(() => { @@ -82,9 +87,10 @@ const InstancePage = ({ routes = [] }: Props) => { dispatch(fetchConnectedInstanceInfoAction(connectionInstanceId)) if (contextInstanceId && contextInstanceId !== connectionInstanceId) { - // rerender children from scratch to clear all component states - setIsShouldChildrenRerender(true) - requestAnimationFrame(() => setIsShouldChildrenRerender(false)) + // rerender children only if the same page from scratch to clear all component states + if (lastPageRef.current === getPageName(connectionInstanceId, pathname)) { + setIsShouldChildrenRerender(true) + } resetContext() } @@ -93,6 +99,14 @@ const InstancePage = ({ routes = [] }: Props) => { dispatch(setDbConfig(localStorageService.get(BrowserStorageItem.dbConfig + connectionInstanceId))) }, [connectionInstanceId]) + useEffect(() => { + lastPageRef.current = getPageName(connectionInstanceId, pathname) + }, [pathname]) + + useEffect(() => { + if (isShouldChildrenRerender) setIsShouldChildrenRerender(false) + }, [isShouldChildrenRerender]) + useEffect(() => () => { setSizes((prevSizes: ResizablePanelSize) => { localStorageService.set(BrowserStorageItem.cliResizableContainer, { diff --git a/redisinsight/ui/src/slices/oauth/cloud.ts b/redisinsight/ui/src/slices/oauth/cloud.ts index e6c69cd543..e666c3dbdf 100644 --- a/redisinsight/ui/src/slices/oauth/cloud.ts +++ b/redisinsight/ui/src/slices/oauth/cloud.ts @@ -12,7 +12,6 @@ import { } from 'uiSrc/components/notifications/components' import successMessages from 'uiSrc/components/notifications/success-messages' import { getCloudSsoUtmParams } from 'uiSrc/utils/oauth/cloudSsoUtm' -import { resetKeys } from 'uiSrc/slices/browser/keys' import { CloudUser } from 'apiSrc/modules/cloud/user/models' import { CloudJobInfo } from 'apiSrc/modules/cloud/job/models' import { CloudSubscriptionPlanResponse } from 'apiSrc/modules/cloud/subscription/dto' @@ -33,7 +32,6 @@ import { removeInfiniteNotification } from '../app/notifications' import { checkConnectToInstanceAction, setConnectedInstanceId } from '../instances/instances' -import { setAppContextInitialState } from '../app/context' export const initialState: StateAppOAuth = { loading: false, @@ -245,8 +243,6 @@ export function createFreeDbSuccess(id: string, history: any) { dispatch(removeInfiniteNotification(InfiniteMessagesIds.oAuthSuccess)) if (!isConnected) { - dispatch(resetKeys()) - dispatch(setAppContextInitialState()) dispatch(setConnectedInstanceId(id ?? '')) dispatch(checkConnectToInstanceAction(id)) } diff --git a/redisinsight/ui/src/utils/routing.ts b/redisinsight/ui/src/utils/routing.ts index 68cbd3f3a1..0f4dd6670a 100644 --- a/redisinsight/ui/src/utils/routing.ts +++ b/redisinsight/ui/src/utils/routing.ts @@ -43,3 +43,5 @@ export const getRedirectionPage = (pageInput: string, databaseId?: string): Null return `/${page}` } } + +export const getPageName = (databaseId: string, path: string) => path?.replace(`/${databaseId}`, '') diff --git a/redisinsight/ui/src/utils/tests/routing.spec.ts b/redisinsight/ui/src/utils/tests/routing.spec.ts index 6f823102b9..d0f203bf4a 100644 --- a/redisinsight/ui/src/utils/tests/routing.spec.ts +++ b/redisinsight/ui/src/utils/tests/routing.spec.ts @@ -1,4 +1,4 @@ -import { getRedirectionPage } from 'uiSrc/utils/routing' +import { getPageName, getRedirectionPage } from 'uiSrc/utils/routing' jest.mock('uiSrc/utils/routing', () => ({ ...jest.requireActual('uiSrc/utils/routing') @@ -25,3 +25,20 @@ describe('getRedirectionPage', () => { } ) }) + +const getPageNameTests = [ + { input: ['instanceId', '/instanceId/page1'], expected: '/page1' }, + { input: ['instanceId', '/instanceId/page1/page2'], expected: '/page1/page2' }, + { input: ['instanceId', '/page1'], expected: '/page1' }, +] + +describe('getPageName', () => { + test.each(getPageNameTests)( + '%j', + ({ input, expected }) => { + // @ts-ignore + const result = getPageName(...input) + expect(result).toEqual(expected) + } + ) +}) From cf687865dce3c88497bcbbda6b549881f0f207d3 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Fri, 17 Nov 2023 13:45:47 +0100 Subject: [PATCH 59/96] add verification --- tests/e2e/docker.web.docker-compose.yml | 2 +- tests/e2e/local.web.docker-compose.yml | 2 +- .../web/regression/tree-view/tree-view.e2e.ts | 38 ++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 99d2099406..9cd09a2e2f 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -33,7 +33,7 @@ services: command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', - 'npx', 'yarn', 'test:chrome:ci' + 'npm', 'run', 'test:chrome:ci' ] # Built image diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index 554821fdc9..361f8a0acd 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -33,7 +33,7 @@ services: command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', - 'npx', 'yarn', 'test:chrome:ci' + 'npm', 'run', 'test:chrome:ci' ] # Redisinsight API + UI build diff --git a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts index 75db76c555..8acd8977d0 100644 --- a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts @@ -2,7 +2,7 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, - ossStandaloneBigConfig, + ossStandaloneBigConfig, ossStandaloneConfig, ossStandaloneConfigEmpty, ossStandaloneRedisearch } from '../../../../helpers/conf'; @@ -10,6 +10,7 @@ import { KeyTypesTexts, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; import { Common } from '../../../../helpers/common'; +import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); @@ -145,3 +146,38 @@ test actualItemsArray = await browserPage.TreeView.getAllItemsArray(); await t.expect(actualItemsArray).eql(expectedSortedByDESC); }); + +test + .before(async() => { + await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); + }) + .after(async() => { + await browserPage.Cli.sendCommandInCli('flushdb'); + await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Verify that if filtering results has only 1 folder, the folder will be expanded', async t => { + const name = Common.generateWord(10); + const additionalCharacter = Common.generateWord(1); + const keyName1 = Common.generateWord(3); + const keyName2 = Common.generateWord(3); + keyNames = [`${name}${additionalCharacter}:${keyName1}`, `${name}${additionalCharacter}:${keyName2}`, name]; + + const commands = [ + 'flushdb', + `HSET ${keyNames[0]} field value`, + `HSET ${keyNames[1]} field value`, + `HSET ${keyNames[2]} field value` + ]; + + // Create 5 keys + await browserPage.Cli.sendCommandsInCli(commands); + await t.click(browserPage.treeViewButton); + await browserPage.searchByKeyName(`${name}${additionalCharacter}*`); + + await verifyKeysDisplayingInTheList([keyName1, keyName2], true); + + await browserPage.searchByKeyName(`${name}${additionalCharacter}`); + + await verifyKeysDisplayingInTheList([keyName1, keyName2], false); + + }); + From eca9015da33de41671e092f148197a8749020055 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Fri, 17 Nov 2023 13:47:12 +0100 Subject: [PATCH 60/96] #RI-5162 - fix tests --- redisinsight/ui/src/utils/test-utils.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index f1f55c4ed2..ea7cd44ce1 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -253,6 +253,7 @@ jest.mock('uiSrc/constants/recommendations', () => ({ // mock to not import routes jest.mock('uiSrc/utils/routing', () => ({ + ...jest.requireActual('uiSrc/utils/routing'), getRedirectionPage: jest.fn(), })) @@ -281,7 +282,7 @@ const matchMediaMock = () => ({ Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation(query => matchMediaMock(query)), + value: jest.fn().mockImplementation((query) => matchMediaMock(query)), }) export const getMswResourceURL = (path: string = '') => RESOURCES_BASE_URL.concat(path) From 61d7ef3a9d3d6ac88f9540a5ae8066c33c24ec67 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Fri, 17 Nov 2023 14:52:19 +0100 Subject: [PATCH 61/96] fix open method --- .../components/browser/tree-view.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/e2e/pageObjects/components/browser/tree-view.ts b/tests/e2e/pageObjects/components/browser/tree-view.ts index d3fd5c75ae..3e7e041962 100644 --- a/tests/e2e/pageObjects/components/browser/tree-view.ts +++ b/tests/e2e/pageObjects/components/browser/tree-view.ts @@ -71,8 +71,8 @@ export class TreeView { await t.click(this.treeViewSettingsBtn); await t.click(this.sortingBtn); order === 'ASC' - ? await t.click(this.sortingASCoption) - : await t.click(this.sortingDESCoption) + ? await t.click(this.sortingASCoption) + : await t.click(this.sortingDESCoption); // Click on save button await t.click(this.treeViewDelimiterValueSave); @@ -93,11 +93,11 @@ export class TreeView { */ async openTreeFolders(names: string[]): Promise { let base = `node-item_${names[0]}:`; - await t.click(Selector(`[data-testid="${base}"]`)); + await this.clickElementIfNotExpanded(base, names[0]); if (names.length > 1) { for (let i = 1; i < names.length; i++) { base = `${base }${names[i]}:`; - await t.click(Selector(`[data-testid="${base}"]`)); + await this.clickElementIfNotExpanded(base, names[i]); } } } @@ -120,4 +120,17 @@ export class TreeView { return textArray; } + + /** + * click on the folder element if it is not expanded + * @param base the base element + * @param name of the folder + */ + private async clickElementIfNotExpanded(base: string, name: string): Promise { + const folderArrow = Selector(`[data-test-subj="node-arrow-icon_"${name[0]}"]`); + const baseSelector = Selector(`[data-testid="${base}"]`); + if (await baseSelector.find('div').count === 0 && await folderArrow.exists) { + await t.click(Selector(`[data-testid="${base}"]`)); + } + } } From 305fcc5b43ac3a03796d8db3a48bf135fdc6366d Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 20 Nov 2023 12:26:09 +0400 Subject: [PATCH 62/96] #RI-5131 - update --- .../virtual-tree/VirtualTree.spec.tsx | 69 +++++++------------ .../components/virtual-tree/VirtualTree.tsx | 13 ++-- 2 files changed, 32 insertions(+), 50 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index 9c169fda9a..77ab0073a2 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -2,7 +2,6 @@ import React from 'react' import { mock, instance } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' -import * as Services from 'uiSrc/services' import VirtualTree, { Props } from './VirtualTree' const mockedProps = mock() @@ -16,37 +15,36 @@ const mockedItems = [ }, ] -const mockTreeItem = { - children: [{ +export const mockVirtualTreeResult = [ + { + children: [{ + children: [], + fullName: 'car:110:', + id: '0.snc1rc3zwgo', + keyApproximate: 0.01, + keyCount: 1, + name: '110', + }], + fullName: 'car:', + id: '0.sz1ie1koqi8', + keyApproximate: 47.18, + keyCount: 4718, + name: 'car', + }, + { children: [], - fullName: 'car:110:', - id: '0.snc1rc3zwgo', + fullName: 'test', + id: '0.snc1rc3zwg1o', keyApproximate: 0.01, keyCount: 1, - name: '110', - }], - fullName: 'car:', - id: '0.sz1ie1koqi8', - keyApproximate: 47.18, - keyCount: 4718, - name: 'car', -} - -const mockTreeItem2 = { - children: [], - fullName: 'test', - id: '0.snc1rc3zwg1o', - keyApproximate: 0.01, - keyCount: 1, - name: 'test', -} - -export const mockVirtualTreeResult = [mockTreeItem, mockTreeItem2] + name: 'test', + } +] jest.mock('uiSrc/services', () => ({ __esModule: true, ...jest.requireActual('uiSrc/services'), - useDisposableWebworker: jest.fn().mockReturnValue({ result: mockVirtualTreeResult, run: jest.fn() }) + useDisposableWebworker: () => ({ result: mockVirtualTreeResult, run: jest.fn() }) })) describe('VirtualTree', () => { @@ -68,9 +66,6 @@ describe('VirtualTree', () => { }) it('should render items', async () => { - jest.spyOn(Services, 'useDisposableWebworker') - .mockReturnValueOnce({ result: [mockTreeItem, mockTreeItem2], run: jest.fn() }) - const mockFn = jest.fn() const { queryByTestId } = render( { expect(queryByTestId('node-item_test')).toBeInTheDocument() }) - it('should call onStatusOpen if only one folder is exist', () => { - jest.spyOn(Services, 'useDisposableWebworker') - .mockReturnValueOnce({ result: [mockTreeItem], run: jest.fn() }) - - const mockFn = jest.fn() - const mockOnStatusOpen = jest.fn() - - render( - - ) - - expect(mockOnStatusOpen).toBeCalledWith('car:', true) - }) - it('should not call onStatusOpen if more than one folder is exist', () => { const mockFn = jest.fn() const mockOnStatusOpen = jest.fn() diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index a0d073b977..5d0d7d6747 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -63,7 +63,7 @@ const VirtualTree = (props: Props) => { onStatusOpen, onStatusSelected, setConstructingTree, - webworkerFn = () => {}, + webworkerFn = () => { }, onDeleteClicked, onDeleteLeaf, } = props @@ -95,9 +95,7 @@ const VirtualTree = (props: Props) => { rerender({}) setConstructingTree?.(false) - if (nodes?.current?.length === 1) { - onStatusOpen?.(nodes.current[0].fullName, true) - } + openSingleFolderNode(nodes.current) }, [result]) useEffect(() => { @@ -209,6 +207,13 @@ const VirtualTree = (props: Props) => { node, }) + const openSingleFolderNode = useCallback((nodes?: TreeNode[]) => { + if (nodes?.length === 1) { + onStatusOpen?.(nodes[0].fullName, true) + openSingleFolderNode(nodes[0].children) + } + }, [onStatusOpen]) + // The `treeWalker` function runs only on tree re-build which is performed // whenever the `treeWalker` prop is changed. const treeWalker = useCallback( From 26ba14f1f3f52e59416810499f1387eeb5c2f9de Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 20 Nov 2023 13:16:53 +0400 Subject: [PATCH 63/96] #RI-5131 - resolve comments --- .../browser/components/virtual-tree/VirtualTree.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index 5d0d7d6747..5092080298 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -207,10 +207,12 @@ const VirtualTree = (props: Props) => { node, }) - const openSingleFolderNode = useCallback((nodes?: TreeNode[]) => { - if (nodes?.length === 1) { - onStatusOpen?.(nodes[0].fullName, true) - openSingleFolderNode(nodes[0].children) + const openSingleFolderNode = useCallback((treeNodes?: TreeNode[]) => { + let nodes = treeNodes + while (nodes?.length === 1) { + const singleNode = nodes[0] + onStatusOpen?.(singleNode.fullName, true) + nodes = singleNode.children } }, [onStatusOpen]) From 893771d7af2eb940a2ca139a8795a11903bd3fc7 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 11:24:27 +0100 Subject: [PATCH 64/96] added extended attribute --- .../virtual-tree/components/Node/Node.tsx | 2 +- .../pageObjects/components/browser/tree-view.ts | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx index 1bc3f02205..597fe86359 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx @@ -154,7 +154,7 @@ const Node = ({ onKeyDown={handleKeyDown} tabIndex={0} onFocus={() => {}} - data-testid={`node-item_${fullName}`} + data-testid={`node-item_${fullName}${isOpen && !isLeaf ? '--expanded' : ''}`} > {!isLeaf && } {isLeaf && } diff --git a/tests/e2e/pageObjects/components/browser/tree-view.ts b/tests/e2e/pageObjects/components/browser/tree-view.ts index 3e7e041962..1a5c7d473c 100644 --- a/tests/e2e/pageObjects/components/browser/tree-view.ts +++ b/tests/e2e/pageObjects/components/browser/tree-view.ts @@ -93,11 +93,11 @@ export class TreeView { */ async openTreeFolders(names: string[]): Promise { let base = `node-item_${names[0]}:`; - await this.clickElementIfNotExpanded(base, names[0]); + await this.clickElementIfNotExpanded(base); if (names.length > 1) { for (let i = 1; i < names.length; i++) { base = `${base }${names[i]}:`; - await this.clickElementIfNotExpanded(base, names[i]); + await this.clickElementIfNotExpanded(base); } } } @@ -124,13 +124,12 @@ export class TreeView { /** * click on the folder element if it is not expanded * @param base the base element - * @param name of the folder */ - private async clickElementIfNotExpanded(base: string, name: string): Promise { - const folderArrow = Selector(`[data-test-subj="node-arrow-icon_"${name[0]}"]`); - const baseSelector = Selector(`[data-testid="${base}"]`); - if (await baseSelector.find('div').count === 0 && await folderArrow.exists) { - await t.click(Selector(`[data-testid="${base}"]`)); + private async clickElementIfNotExpanded(base: string): Promise { + const baseSelector = Selector(`[data-testid^="${base}"]`); + const elementSelector = await baseSelector.getAttribute('data-testid'); + if (!elementSelector?.includes('expanded')) { + await t.click(Selector(`[data-testid^="${base}"]`)); } } } From b700e55f18a132d9078131dd9f001e2d8eb5b442 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 12:27:53 +0100 Subject: [PATCH 65/96] fix browser action --- tests/e2e/common-actions/browser-actions.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/common-actions/browser-actions.ts b/tests/e2e/common-actions/browser-actions.ts index 836fa6235e..03a1f9585a 100644 --- a/tests/e2e/common-actions/browser-actions.ts +++ b/tests/e2e/common-actions/browser-actions.ts @@ -108,7 +108,10 @@ export class BrowserActions { for (let j = 0; j < innerFoldersNumber; j++) { const nodeName = this.getNodeName(prevNodeSelector, folders[i][j], delimiter); const node = this.getNodeSelector(nodeName); - await t.click(node); + const fullTestIdSelector = await node.getAttribute('data-testid'); + if (!fullTestIdSelector?.includes('expanded')) { + await t.click(node); + } prevNodeSelector = nodeName; } From 5a6a4e088ef10cc2a7c83d517b32b69cccfde6e3 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 12:29:45 +0100 Subject: [PATCH 66/96] fix locator --- tests/e2e/common-actions/browser-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/common-actions/browser-actions.ts b/tests/e2e/common-actions/browser-actions.ts index 03a1f9585a..fcc6aceaaf 100644 --- a/tests/e2e/common-actions/browser-actions.ts +++ b/tests/e2e/common-actions/browser-actions.ts @@ -87,7 +87,7 @@ export class BrowserActions { * @param name node name */ getNodeSelector(name: string): Selector { - return Selector(`[data-testid="node-item_${name}"]`); + return Selector(`[data-testid^="node-item_${name}"]`); } /** From b3a8f3a1bbbff49a18bba98408f7ebad4ed5b0cf Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 13:29:56 +0100 Subject: [PATCH 67/96] add comments --- .../web/regression/tree-view/tree-view.e2e.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts index 8acd8977d0..1a34378be8 100644 --- a/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/web/regression/tree-view/tree-view.e2e.ts @@ -2,7 +2,8 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, - ossStandaloneBigConfig, ossStandaloneConfig, + ossStandaloneBigConfig, + ossStandaloneConfig, ossStandaloneConfigEmpty, ossStandaloneRedisearch } from '../../../../helpers/conf'; @@ -147,6 +148,7 @@ test await t.expect(actualItemsArray).eql(expectedSortedByDESC); }); +https://redislabs.atlassian.net/browse/RI-5131 test .before(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); @@ -167,17 +169,15 @@ test `HSET ${keyNames[1]} field value`, `HSET ${keyNames[2]} field value` ]; - - // Create 5 keys await browserPage.Cli.sendCommandsInCli(commands); await t.click(browserPage.treeViewButton); - await browserPage.searchByKeyName(`${name}${additionalCharacter}*`); + // Verify if there is only folder, a user can see keys inside + await browserPage.searchByKeyName(`${name}${additionalCharacter}*`); await verifyKeysDisplayingInTheList([keyName1, keyName2], true); - await browserPage.searchByKeyName(`${name}${additionalCharacter}`); - + // Verify if there are folder and key, a user can't see keys inside the folder + await browserPage.searchByKeyName(`${name}*`); await verifyKeysDisplayingInTheList([keyName1, keyName2], false); - }); From 1153b028e19afa880c44a81829edc2fbfabdb905 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 13:53:27 +0100 Subject: [PATCH 68/96] firts test --- tests/e2e/desktop.runner.ts | 1 + tests/e2e/desktop.runner.win.ts | 1 + tests/e2e/package.json | 10 +- .../web/regression/database/github.e2e.ts | 10 +- tests/e2e/web.runner.ts | 3 +- tests/e2e/yarn.lock | 149 ++++++++++++------ 6 files changed, 110 insertions(+), 64 deletions(-) diff --git a/tests/e2e/desktop.runner.ts b/tests/e2e/desktop.runner.ts index a0f6be3b94..c2081947e6 100644 --- a/tests/e2e/desktop.runner.ts +++ b/tests/e2e/desktop.runner.ts @@ -39,6 +39,7 @@ import testcafe from 'testcafe'; assertionTimeout: 5000, speed: 1, quarantineMode: { successThreshold: 1, attemptLimit: 3 }, + disableMultipleWindows: true }); }) .then((failedCount) => { diff --git a/tests/e2e/desktop.runner.win.ts b/tests/e2e/desktop.runner.win.ts index 5755eeb38d..17de2317e2 100644 --- a/tests/e2e/desktop.runner.win.ts +++ b/tests/e2e/desktop.runner.win.ts @@ -39,6 +39,7 @@ import testcafe from 'testcafe'; assertionTimeout: 5000, speed: 1, quarantineMode: { successThreshold: 1, attemptLimit: 3 }, + disableMultipleWindows: true }); }) .then((failedCount) => { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index d05c168430..d2ac12d0f5 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -13,7 +13,7 @@ "build:web": "yarn --cwd ../../ build:web", "redis:last": "docker run --name redis-last-version -p 7777:6379 -d redislabs/redismod", "start:app": "cross-env SERVER_STATIC_CONTENT=true yarn start:api", - "test:chrome": "testcafe --compiler-options typescript.configPath=tsconfig.testcafe.json --cache --concurrency 1 chrome tests/ -r html:./report/report.html,spec -e -s takeOnFails=true,path=report/screenshots/,pathPattern=${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png", + "test:chrome": "testcafe --compiler-options typescript.configPath=tsconfig.testcafe.json --cache --disable-multiple-windows --concurrency 1 chrome tests/ -r html:./report/report.html,spec -e -s takeOnFails=true,path=report/screenshots/,pathPattern=${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png", "test:chrome:ci": "ts-node ./web.runner.ts", "test": "yarn test:chrome", "lint": "eslint . --ext .ts,.js,.tsx,.jsx", @@ -30,9 +30,7 @@ "@types/lodash": "4.14.192", "@types/node": "20.3.1", "word-wrap": "1.2.4", - "**/semver": "^7.5.2", - "testcafe-hammerhead": "31.4.5", - "testcafe-hammerhead/tough-cookie": "^4.1.3" + "**/semver": "^7.5.2" }, "devDependencies": { "@types/archiver": "^5.3.2", @@ -56,11 +54,11 @@ "redis": "3.1.1", "sqlite3": "^5.1.6", "supertest": "^4.0.2", - "testcafe": "3.0.0", + "testcafe": "3.3.0", "testcafe-browser-provider-electron": "0.0.19", "testcafe-reporter-html": "1.4.6", "testcafe-reporter-json": "2.2.0", - "testcafe-reporter-spec": "2.1.1", + "testcafe-reporter-spec": "2.2.0", "ts-node": "10.9.1", "typescript": "5.1.3" } diff --git a/tests/e2e/tests/web/regression/database/github.e2e.ts b/tests/e2e/tests/web/regression/database/github.e2e.ts index d32ad3ce98..bb8eb16940 100644 --- a/tests/e2e/tests/web/regression/database/github.e2e.ts +++ b/tests/e2e/tests/web/regression/database/github.e2e.ts @@ -4,6 +4,7 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { MyRedisDatabasePage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { Common } from '../../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); const databaseHelper = new DatabaseHelper(); @@ -23,7 +24,7 @@ fixture `Github functionality` // Delete database await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test('Verify that user can work with Github link in the application', async t => { +test.only('Verify that user can work with Github link in the application', async t => { // Verify that user can see the icon for GitHub reference at the bottom of the left side bar in the List of DBs await t.expect(myRedisDatabasePage.NavigationPanel.githubButton.visible).ok('Github button not found'); //Verify that user can see the icon for GitHub reference at the bottom of the left side bar on the Browser page @@ -33,8 +34,7 @@ test('Verify that user can work with Github link in the application', async t => await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); await t.expect(myRedisDatabasePage.NavigationPanel.githubButton.visible).ok('Github button'); // update after resolving testcafe Native Automation mode limitations - // // Verify that when user clicks on Github icon he redirects to the URL: https://github.com/RedisInsight/RedisInsight - // await t.click(myRedisDatabasePage.NavigationPanel.githubButton); - // await t.expect(getPageUrl()).contains('https://github.com/RedisInsight/RedisInsight', 'Link is not correct'); - // await t.switchToParentWindow(); + // Verify that when user clicks on Github icon he redirects to the URL: https://github.com/RedisInsight/RedisInsight + await t.click(myRedisDatabasePage.NavigationPanel.githubButton); + await Common.checkURLContainsText('https://github.com/RedisInsight/RedisInsight'); }); diff --git a/tests/e2e/web.runner.ts b/tests/e2e/web.runner.ts index f82a61baab..c28353bfad 100644 --- a/tests/e2e/web.runner.ts +++ b/tests/e2e/web.runner.ts @@ -39,7 +39,8 @@ import testcafe from 'testcafe'; assertionTimeout: 5000, speed: 1, quarantineMode: { successThreshold: 1, attemptLimit: 3 }, - pageRequestTimeout: 8000 + pageRequestTimeout: 8000, + disableMultipleWindows: true }); }) .then((failedCount) => { diff --git a/tests/e2e/yarn.lock b/tests/e2e/yarn.lock index 733f67861c..fd6df09eb4 100644 --- a/tests/e2e/yarn.lock +++ b/tests/e2e/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.3.0-rc.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28" + integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg== + "@ampproject/remapping@^2.2.0": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" @@ -1110,12 +1115,18 @@ resolved "https://registry.yarnpkg.com/@devexpress/bin-v8-flags-filter/-/bin-v8-flags-filter-1.3.0.tgz#3069f2525c0c5fb940810e9ec10fc592c47552db" integrity sha512-LWLNfYGwVJKYpmHUDoODltnlqxdEAl5Qmw7ha1+TSpsABeF94NKSWkQTTV1TB4CM02j2pZyqn36nHgaFl8z7qw== -"@devexpress/error-stack-parser@^2.0.6": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@devexpress/error-stack-parser/-/error-stack-parser-2.0.6.tgz#a7c32e54583566bc6abf153c32a8b86d87d1e490" - integrity sha512-fneVypElGUH6Be39mlRZeAu00pccTlf4oVuzf9xPJD1cdEqI8NyAiQua/EW7lZdrbMUbgyXcJmfKPefhYius3A== +"@devexpress/callsite-record@^4.1.6": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@devexpress/callsite-record/-/callsite-record-4.1.7.tgz#045ece71df8c574d7adc15c75eeaac6c6ca6c97d" + integrity sha512-qr3VQYc0KopduFkEY6SxaOIi1Xhm0jIWQfrxxMVboI/p2rjF/Mj/iqaiUxQQP6F3ujpW/7l0mzhf17uwcFZhBA== dependencies: - stackframe "^1.1.1" + "@types/lodash" "^4.14.72" + callsite "^1.0.0" + chalk "^2.4.0" + error-stack-parser "^2.1.4" + highlight-es "^1.0.0" + lodash "4.6.1 || ^4.16.1" + pinkie-promise "^2.0.0" "@electron/asar@^3.2.3": version "3.2.4" @@ -1898,19 +1909,6 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" -callsite-record@^4.0.0: - version "4.1.5" - resolved "https://registry.yarnpkg.com/callsite-record/-/callsite-record-4.1.5.tgz#cfccae67dfd29e0e52a17d88517fc7e4e3d3bdb4" - integrity sha512-OqeheDucGKifjQRx524URgV4z4NaKjocGhygTptDea+DLROre4ZEecA4KXDq+P7qlGCohYVNOh3qr+y5XH5Ftg== - dependencies: - "@devexpress/error-stack-parser" "^2.0.6" - "@types/lodash" "^4.14.72" - callsite "^1.0.0" - chalk "^2.4.0" - highlight-es "^1.0.0" - lodash "4.6.1 || ^4.16.1" - pinkie-promise "^2.0.0" - callsite@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -2422,6 +2420,11 @@ enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + env-paths@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" @@ -2439,12 +2442,12 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -error-stack-parser@^1.3.6: - version "1.3.6" - resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-1.3.6.tgz#e0e73b93e417138d1cd7c0b746b1a4a14854c292" - integrity sha512-xhuSYd8wLgOXwNgjcPeXMPL/IiiA1Huck+OPvClpJViVNNlJVtM41o+1emp7bPvlCJwCatFX2DWc05/DgfbWzA== +error-stack-parser@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz#229cb01cdbfa84440bfa91876285b94680188286" + integrity sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ== dependencies: - stackframe "^0.3.1" + stackframe "^1.3.4" es-abstract@^1.19.0, es-abstract@^1.20.4: version "1.21.2" @@ -4366,6 +4369,13 @@ parse5@^1.5.0: resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94" integrity sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA== +parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -4859,7 +4869,7 @@ sanitize-filename@^1.6.0: dependencies: truncate-utf8-bytes "^1.0.0" -"semver@2 || 3 || 4 || 5", semver@5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.3.1, semver@^7.2.1, semver@^7.3.5, semver@^7.5.2: +"semver@2 || 3 || 4 || 5", semver@5.5.0, semver@7.5.3, semver@^6.0.0, semver@^6.3.1, semver@^7.2.1, semver@^7.3.5, semver@^7.5.2, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -5036,12 +5046,7 @@ ssri@^8.0.0, ssri@^8.0.1: dependencies: minipass "^3.1.1" -stackframe@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-0.3.1.tgz#33aa84f1177a5548c8935533cbfeb3420975f5a4" - integrity sha512-XmoiF4T5nuWEp2x2w92WdGjdHGY/cZa6LIbRsDRQR/Xlk4uW0PAUlH1zJYVffocwKpCdwyuypIp25xsSXEtZHw== - -stackframe@^1.1.1: +stackframe@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310" integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw== @@ -5221,10 +5226,10 @@ testcafe-browser-provider-electron@0.0.19: promisify-event "^1.0.0" proxyquire "^1.7.10" -testcafe-browser-tools@2.0.25: - version "2.0.25" - resolved "https://registry.yarnpkg.com/testcafe-browser-tools/-/testcafe-browser-tools-2.0.25.tgz#9721f9ba7024a1c95ca4675554cf9477d792edd2" - integrity sha512-LK/ZOJUwnpjdJl131qrBN0toCv2wZj2Elb8UPTU71n9Woq7kZtGine4P5XvvvO7mE8bjBfWJOBW9jRhHxyIWzQ== +testcafe-browser-tools@2.0.26: + version "2.0.26" + resolved "https://registry.yarnpkg.com/testcafe-browser-tools/-/testcafe-browser-tools-2.0.26.tgz#38b5c0c2cd438895de12ee53cae11c64bd33aab9" + integrity sha512-nTKSJhBzn9BmnOs0xVzXMu8dN2Gu13Ca3x3SJr/zF6ZdKjXO82JlbHu55dt5MFoWjzAQmwlqBkSxPaYicsTgUw== dependencies: array-find "^1.0.0" debug "^4.3.1" @@ -5244,7 +5249,38 @@ testcafe-browser-tools@2.0.25: read-file-relative "^1.2.0" which-promise "^1.0.0" -testcafe-hammerhead@31.4.5, testcafe-hammerhead@>=19.4.0: +testcafe-hammerhead@31.6.1: + version "31.6.1" + resolved "https://registry.yarnpkg.com/testcafe-hammerhead/-/testcafe-hammerhead-31.6.1.tgz#181fe81cf10bd43115087d004dc6c2cad0d02f35" + integrity sha512-tMdF183bTL+hMNzIdUUNpg32T2hlwaI9CEXxOJpgg6VnzCpy1RDV5+wcIJB1ywhs6cdd5ltQZuaHrm1tWbyR1A== + dependencies: + "@adobe/css-tools" "^4.3.0-rc.1" + "@electron/asar" "^3.2.3" + acorn-hammerhead "0.6.2" + bowser "1.6.0" + crypto-md5 "^1.0.0" + debug "4.3.1" + esotope-hammerhead "0.6.4" + http-cache-semantics "^4.1.0" + httpntlm "^1.8.10" + iconv-lite "0.5.1" + lodash "^4.17.20" + lru-cache "2.6.3" + match-url-wildcard "0.0.4" + merge-stream "^1.0.1" + mime "~1.4.1" + mustache "^2.1.1" + nanoid "^3.1.12" + os-family "^1.0.0" + parse5 "^7.1.2" + pinkie "2.0.4" + read-file-relative "^1.2.0" + semver "7.5.3" + tough-cookie "4.1.3" + tunnel-agent "0.6.0" + ws "^7.4.6" + +testcafe-hammerhead@>=19.4.0: version "31.4.5" resolved "https://registry.yarnpkg.com/testcafe-hammerhead/-/testcafe-hammerhead-31.4.5.tgz#672ae1ee426f42fcedb8f15c3e8350f198af2670" integrity sha512-L6flQbgQ4IoCqtjjOVPoJsaq6++9/mYKVCDxUjn5FhMyP/P3pfLcpW0h1wV8UiNkcf6WoYVYhwxN/15g5YMgIQ== @@ -5314,12 +5350,7 @@ testcafe-reporter-minimal@^2.2.0: resolved "https://registry.yarnpkg.com/testcafe-reporter-minimal/-/testcafe-reporter-minimal-2.2.0.tgz#d12624bb6f6b98543ca52512b01002cad23b657d" integrity sha512-iUSWI+Z+kVUAsGegMmEXKDiMPZHDxq+smo4utWwc3wI3Tk6jT8PbNvsROQAjwkMKDmnpo6To5vtyvzvK+zKGXA== -testcafe-reporter-spec@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/testcafe-reporter-spec/-/testcafe-reporter-spec-2.1.1.tgz#8156fced0f5132486559ad560bc80676469275ec" - integrity sha512-KO4c4F5pIORaQ1ddWgNDOyN0GiiKFWtjoMYk3VgBiJYcYuk2ZPN1Ewn0KkZsSsL30tOKeQW6jdp/H+7b4rg5+Q== - -testcafe-reporter-spec@^2.2.0: +testcafe-reporter-spec@2.2.0, testcafe-reporter-spec@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/testcafe-reporter-spec/-/testcafe-reporter-spec-2.2.0.tgz#5c17095da6c680702f34bffb8e3d53bf465125ea" integrity sha512-4jUN75Y7eaHQfSjiCLBXt/TvJMW76kBaZGC74sq03FJNBLoo8ibkEFzfjDJzNDCRYo+P7FjCx3vxGrzgfQU26w== @@ -5339,10 +5370,10 @@ testcafe-selector-generator@^0.1.0: resolved "https://registry.yarnpkg.com/testcafe-selector-generator/-/testcafe-selector-generator-0.1.0.tgz#852c86f71565e5d9320da625c2260d040cbed786" integrity sha512-MTw+RigHsEYmFgzUFNErDxui1nTYUk6nm2bmfacQiKPdhJ9AHW/wue4J/l44mhN8x3E8NgOUkHHOI+1TDFXiLQ== -testcafe@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/testcafe/-/testcafe-3.0.0.tgz#c1be0fe770794cfc773651c40e23ed0096db633d" - integrity sha512-znaK7JQ2kGdEJq2kDmI5sP6QDDkaqwGCxBVYEBOWRr7lUj/bAyFj6x3n3n1WoA3JcwhkRrxJQXQSk/QDgKCCUw== +testcafe@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/testcafe/-/testcafe-3.3.0.tgz#1446b1acca98b1e43c6843c49fce179c38754af4" + integrity sha512-fffFnuZwAlZ4y9mkjygHZUGXad/7pXuj/RHkuwvft0GjRDqRHIcfR5aWrVLGnGpFeO55l48Z2kq1SxT2I+879g== dependencies: "@babel/core" "^7.12.1" "@babel/plugin-proposal-async-generator-functions" "^7.12.1" @@ -5361,13 +5392,13 @@ testcafe@3.0.0: "@babel/preset-react" "^7.12.1" "@babel/runtime" "^7.12.5" "@devexpress/bin-v8-flags-filter" "^1.3.0" + "@devexpress/callsite-record" "^4.1.6" "@types/node" "^12.20.10" async-exit-hook "^1.1.2" babel-plugin-module-resolver "^5.0.0" babel-plugin-syntax-trailing-function-commas "^6.22.0" bowser "^2.8.1" callsite "^1.0.0" - callsite-record "^4.0.0" chai "4.3.4" chalk "^2.3.0" chrome-remote-interface "^0.32.2" @@ -5382,7 +5413,7 @@ testcafe@3.0.0: email-validator "^2.0.4" emittery "^0.4.1" endpoint-utils "^1.0.2" - error-stack-parser "^1.3.6" + error-stack-parser "^2.1.4" execa "^4.0.3" get-os-info "^1.0.2" globby "^11.0.4" @@ -5420,12 +5451,12 @@ testcafe@3.0.0: resolve-cwd "^1.0.0" resolve-from "^4.0.0" sanitize-filename "^1.6.0" - semver "^5.6.0" + semver "^7.5.3" set-cookie-parser "^2.5.1" source-map-support "^0.5.16" strip-bom "^2.0.0" - testcafe-browser-tools "2.0.25" - testcafe-hammerhead "31.4.5" + testcafe-browser-tools "2.0.26" + testcafe-hammerhead "31.6.1" testcafe-legacy-api "5.1.6" testcafe-reporter-json "^2.1.0" testcafe-reporter-list "^2.2.0" @@ -5470,7 +5501,16 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tough-cookie@4.0.0, tough-cookie@^4.1.3: +tough-cookie@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.1.2" + +tough-cookie@4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== @@ -5638,6 +5678,11 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" +universalify@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" @@ -5811,7 +5856,7 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -ws@^7.2.0: +ws@^7.2.0, ws@^7.4.6: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== From ca3b0c9403575060d785baf38ae146810e630858 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 13:57:36 +0100 Subject: [PATCH 69/96] run 1 test --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e273b4300f..46bdc58b9b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1163,7 +1163,7 @@ workflows: - e2e-tests: name: E2ETest build: docker - parallelism: 4 + parallelism: 1 requires: - Build docker image - e2e-app-image: From 652593c6640418f36bfea0b3a81e97eca01a924a Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Mon, 20 Nov 2023 16:11:12 +0100 Subject: [PATCH 70/96] fix part of the test --- .circleci/config.yml | 2 +- tests/e2e/helpers/utils.ts | 5 ++ .../critical-path/browser/bulk-upload.e2e.ts | 3 +- .../critical-path/database/add-ssh-db.e2e.ts | 43 +++++++----- .../critical-path/browser/bulk-upload.e2e.ts | 3 +- .../cli/cli-command-helper.e2e.ts | 6 +- .../database/connecting-to-the-db.e2e.ts | 49 +++++++------ .../monitor/save-commands.e2e.ts | 19 +++-- .../web/regression/browser/survey-link.e2e.ts | 13 ++-- .../regression/cli/cli-command-helper.e2e.ts | 69 ++++++++----------- .../web/regression/database/github.e2e.ts | 3 +- .../web/regression/shortcuts/shortcuts.e2e.ts | 13 ++-- .../workbench/workbench-pipeline.e2e.ts | 12 ++-- tests/e2e/tests/web/smoke/cli/cli.e2e.ts | 7 +- .../database/add-db-from-welcome-page.e2e.ts | 13 ++-- 15 files changed, 125 insertions(+), 135 deletions(-) create mode 100644 tests/e2e/helpers/utils.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 46bdc58b9b..e273b4300f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1163,7 +1163,7 @@ workflows: - e2e-tests: name: E2ETest build: docker - parallelism: 1 + parallelism: 4 requires: - Build docker image - e2e-app-image: diff --git a/tests/e2e/helpers/utils.ts b/tests/e2e/helpers/utils.ts new file mode 100644 index 0000000000..abd81fdd6a --- /dev/null +++ b/tests/e2e/helpers/utils.ts @@ -0,0 +1,5 @@ +import { ClientFunction } from 'testcafe'; +import { commonUrl } from './conf'; + +export const goBackHistory = ClientFunction(() => window.history.back()); +export const openRedisHomePage = ClientFunction(() => window.open(commonUrl)); diff --git a/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts index fb6d8202b9..11cb0b4bf3 100644 --- a/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/browser/bulk-upload.e2e.ts @@ -61,8 +61,7 @@ test('Verify bulk upload of different text docs formats', async t => { // Verify that user can remove uploaded file await t.setFilesToUpload(browserPage.BulkActions.bulkUploadInput, [filePathes.bigDataFile]); - // update after resolving testcafe Native Automation mode limitations - // await t.expect(browserPage.BulkActions.bulkUploadContainer.textContent).contains(filesToUpload[1], 'Filename not displayed in upload input'); + await t.expect(browserPage.BulkActions.bulkUploadContainer.textContent).contains(filesToUpload[1], 'Filename not displayed in upload input'); await t.click(browserPage.BulkActions.removeFileBtn); await t.expect(browserPage.BulkActions.bulkUploadContainer.textContent).contains(defaultText, 'File not removed from upload input'); diff --git a/tests/e2e/tests/electron/critical-path/database/add-ssh-db.e2e.ts b/tests/e2e/tests/electron/critical-path/database/add-ssh-db.e2e.ts index e5bfba5778..b4f84e9ced 100644 --- a/tests/e2e/tests/electron/critical-path/database/add-ssh-db.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/database/add-ssh-db.e2e.ts @@ -5,13 +5,13 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { sshPrivateKey, sshPrivateKeyWithPasscode } from '../../../../test-data/sshPrivateKeys'; import { Common } from '../../../../helpers/common'; -// import { BrowserActions } from '../../../common-actions/browser-actions'; +import { BrowserActions } from '../../../../common-actions/browser-actions'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); -// const browserActions = new BrowserActions(); +const browserActions = new BrowserActions(); const sshParams = { sshHost: '172.31.100.245', @@ -43,6 +43,12 @@ fixture `Adding database with SSH` await databaseAPIRequests.deleteStandaloneDatabasesByNamesApi([sshDbPass.databaseName, sshDbPrivateKey.databaseName, sshDbPasscode.databaseName, newClonedDatabaseAlias]); }); test('Adding database with SSH', async t => { + const tooltipText = [ + 'Enter a value for required fields (3):', + 'SSH Host', + 'SSH Username', + 'SSH Private Key' + ]; const hiddenPass = '••••••••••••'; const sshWithPass = { ...sshParams, @@ -57,25 +63,24 @@ test('Adding database with SSH', async t => { sshPrivateKey: sshPrivateKeyWithPasscode, sshPassphrase: 'test' }; - // update after resolving testcafe Native Automation mode limitations - // // Verify that if user have not entered any required value he can see that this field should be specified when hover over the button to add a database - // await t - // .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseButton) - // .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseManually) - // .click(myRedisDatabasePage.AddRedisDatabase.useSSHCheckbox) - // .click(myRedisDatabasePage.AddRedisDatabase.sshPrivateKeyRadioBtn) - // .hover(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); - // for (const text of tooltipText) { - // await browserActions.verifyTooltipContainsText(text, true); - // } - // // Verify that user can see the Test Connection button enabled/disabled with the same rules as the button to add/apply the changes - // await t.hover(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - // for (const text of tooltipText) { - // await browserActions.verifyTooltipContainsText(text, true); - // } + // Verify that if user have not entered any required value he can see that this field should be specified when hover over the button to add a database + await t + .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseButton) + .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseManually) + .click(myRedisDatabasePage.AddRedisDatabase.useSSHCheckbox) + .click(myRedisDatabasePage.AddRedisDatabase.sshPrivateKeyRadioBtn) + .hover(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); + for (const text of tooltipText) { + await browserActions.verifyTooltipContainsText(text, true); + } + // Verify that user can see the Test Connection button enabled/disabled with the same rules as the button to add/apply the changes + await t.hover(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); + for (const text of tooltipText) { + await browserActions.verifyTooltipContainsText(text, true); + } // Verify that user can add SSH tunnel with Password for Standalone database - // await t.click(myRedisDatabasePage.AddRedisDatabase.cancelButton); + await t.click(myRedisDatabasePage.AddRedisDatabase.cancelButton); await myRedisDatabasePage.AddRedisDatabase.addStandaloneSSHDatabase(sshDbPass, sshWithPass); await myRedisDatabasePage.clickOnDBByName(sshDbPass.databaseName); await Common.checkURLContainsText('browser'); diff --git a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts index b188a5f811..b7c7a2826d 100644 --- a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts @@ -61,8 +61,7 @@ test('Verify bulk upload of different text docs formats', async t => { // Verify that user can remove uploaded file await t.setFilesToUpload(browserPage.BulkActions.bulkUploadInput, [filePathes.bigDataFile]); - // update after resolving testcafe Native Automation mode limitations - // await t.expect(browserPage.BulkActions.bulkUploadContainer.textContent).contains(filesToUpload[1], 'Filename not displayed in upload input'); + await t.expect(browserPage.BulkActions.bulkUploadContainer.textContent).contains(filesToUpload[1], 'Filename not displayed in upload input'); await t.click(browserPage.BulkActions.removeFileBtn); await t.expect(browserPage.BulkActions.bulkUploadContainer.textContent).contains(defaultText, 'File not removed from upload input'); diff --git a/tests/e2e/tests/web/critical-path/cli/cli-command-helper.e2e.ts b/tests/e2e/tests/web/critical-path/cli/cli-command-helper.e2e.ts index 8c688fd678..51c3f57f2c 100644 --- a/tests/e2e/tests/web/critical-path/cli/cli-command-helper.e2e.ts +++ b/tests/e2e/tests/web/critical-path/cli/cli-command-helper.e2e.ts @@ -90,10 +90,8 @@ test('Verify that user can type TS. in Command helper and see commands from Redi await browserPage.CommandHelper.selectFilterGroupType(COMMAND_GROUP_TIMESERIES); // Search per part of command and check all opened commands await browserPage.CommandHelper.checkSearchedCommandInCommandHelper(commandForSearch, timeSeriesCommands); - // update after resolving testcafe Native Automation mode limitations - // // Check the first command documentation url - // await browserPage.CommandHelper.checkURLCommand(timeSeriesCommands[0], `https://redis.io/commands/${timeSeriesCommands[0].toLowerCase()}/`); - // await t.switchToParentWindow(); + // Check the first command documentation url + await browserPage.CommandHelper.checkURLCommand(timeSeriesCommands[0], `https://redis.io/commands/${timeSeriesCommands[0].toLowerCase()}/`); }); // outdated after https://redislabs.atlassian.net/browse/RI-4608 test.skip('Verify that user can type GRAPH. in Command helper and see auto-suggestions from RedisGraph commands.json', async t => { diff --git a/tests/e2e/tests/web/critical-path/database/connecting-to-the-db.e2e.ts b/tests/e2e/tests/web/critical-path/database/connecting-to-the-db.e2e.ts index 2190d47830..1418413284 100644 --- a/tests/e2e/tests/web/critical-path/database/connecting-to-the-db.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database/connecting-to-the-db.e2e.ts @@ -5,14 +5,14 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { sshPrivateKey, sshPrivateKeyWithPasscode } from '../../../../test-data/sshPrivateKeys'; import { Common } from '../../../../helpers/common'; -// import { BrowserActions } from '../../../common-actions/browser-actions'; +import { BrowserActions } from '../../../../common-actions/browser-actions'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); const welcomePage = new WelcomePage(); -// const browserActions = new BrowserActions(); +const browserActions = new BrowserActions(); const sshParams = { sshHost: '172.31.100.245', @@ -90,12 +90,12 @@ test await databaseAPIRequests.deleteStandaloneDatabasesByNamesApi([sshDbPass.databaseName, sshDbPrivateKey.databaseName, sshDbPasscode.databaseName, newClonedDatabaseAlias]); })('Adding database with SSH', async t => { const hiddenPass = '••••••••••••'; - // const tooltipText = [ - // 'Enter a value for required fields (3):', - // 'SSH Host', - // 'SSH Username', - // 'SSH Private Key' - // ]; + const tooltipText = [ + 'Enter a value for required fields (3):', + 'SSH Host', + 'SSH Username', + 'SSH Private Key' + ]; const sshWithPass = { ...sshParams, sshPassword: 'pass' @@ -109,25 +109,24 @@ test sshPrivateKey: sshPrivateKeyWithPasscode, sshPassphrase: 'test' }; - // update after resolving testcafe Native Automation mode limitations - // // Verify that if user have not entered any required value he can see that this field should be specified when hover over the button to add a database - // await t - // .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseButton) - // .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseManually) - // .click(myRedisDatabasePage.AddRedisDatabase.useSSHCheckbox) - // .click(myRedisDatabasePage.AddRedisDatabase.sshPrivateKeyRadioBtn) - // .hover(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); - // for (const text of tooltipText) { - // await browserActions.verifyTooltipContainsText(text, true); - // } - // // Verify that user can see the Test Connection button enabled/disabled with the same rules as the button to add/apply the changes - // await t.hover(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); - // for (const text of tooltipText) { - // await browserActions.verifyTooltipContainsText(text, true); - // } + // Verify that if user have not entered any required value he can see that this field should be specified when hover over the button to add a database + await t + .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseButton) + .click(myRedisDatabasePage.AddRedisDatabase.addDatabaseManually) + .click(myRedisDatabasePage.AddRedisDatabase.useSSHCheckbox) + .click(myRedisDatabasePage.AddRedisDatabase.sshPrivateKeyRadioBtn) + .hover(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); + for (const text of tooltipText) { + await browserActions.verifyTooltipContainsText(text, true); + } + // Verify that user can see the Test Connection button enabled/disabled with the same rules as the button to add/apply the changes + await t.hover(myRedisDatabasePage.AddRedisDatabase.testConnectionBtn); + for (const text of tooltipText) { + await browserActions.verifyTooltipContainsText(text, true); + } // Verify that user can add SSH tunnel with Password for Standalone database - // await t.click(myRedisDatabasePage.AddRedisDatabase.cancelButton); + await t.click(myRedisDatabasePage.AddRedisDatabase.cancelButton); await myRedisDatabasePage.AddRedisDatabase.addStandaloneSSHDatabase(sshDbPass, sshWithPass); await myRedisDatabasePage.clickOnDBByName(sshDbPass.databaseName); await Common.checkURLContainsText('browser'); diff --git a/tests/e2e/tests/web/critical-path/monitor/save-commands.e2e.ts b/tests/e2e/tests/web/critical-path/monitor/save-commands.e2e.ts index 37fa92cd8d..253e568f8c 100644 --- a/tests/e2e/tests/web/critical-path/monitor/save-commands.e2e.ts +++ b/tests/e2e/tests/web/critical-path/monitor/save-commands.e2e.ts @@ -30,20 +30,19 @@ fixture `Save commands` await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); test('Verify that user can see a tooltip and toggle that allows to save Profiler log or not in the Profiler', async t => { - // const toolTip = [ - // 'Allows you to download the generated log file after pausing the Profiler', - // 'Profiler log is saved to a file on your local machine with no size limitation. The temporary log file will be automatically rewritten when the Profiler is reset.' - // ]; + const toolTip = [ + 'Allows you to download the generated log file after pausing the Profiler', + 'Profiler log is saved to a file on your local machine with no size limitation. The temporary log file will be automatically rewritten when the Profiler is reset.' + ]; await t.click(browserPage.Profiler.expandMonitor); // Check the toggle and Tooltip for Save log await t.expect(browserPage.Profiler.saveLogSwitchButton.exists).ok('The toggle that allows to save Profiler log is not displayed'); - // update after resolving testcafe Native Automation mode limitations - // await t.hover(browserPage.Profiler.saveLogSwitchButton); - // for (const message of toolTip) { - // await t.click(browserPage.Profiler.saveLogSwitchButton); - // await t.expect(browserPage.Profiler.saveLogToolTip.textContent).contains(message, 'The toolTip for save log in Profiler is not displayed'); - // } + await t.hover(browserPage.Profiler.saveLogSwitchButton); + for (const message of toolTip) { + await t.click(browserPage.Profiler.saveLogSwitchButton); + await t.expect(browserPage.Profiler.saveLogToolTip.textContent).contains(message, 'The toolTip for save log in Profiler is not displayed'); + } // Check toggle state await t.expect(browserPage.Profiler.saveLogSwitchButton.getAttribute('aria-checked')).eql('false', 'The toggle state is not OFF when Profiler opened'); }); diff --git a/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts b/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts index 25ccdd89e7..b33bf81cf5 100644 --- a/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts @@ -2,15 +2,16 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { rte } from '../../../../helpers/constants'; import { BrowserPage, MyRedisDatabasePage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; -// import { Common } from '../../../helpers/common'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { Common } from '../../../../helpers/common'; +import { goBackHistory } from '../../../../helpers/utils'; const browserPage = new BrowserPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); -// const externalPageLink = 'https://www.surveymonkey.com/r/redisinsight'; +const externalPageLink = 'https://www.surveymonkey.com/r/redisinsight'; fixture `User Survey` .meta({ @@ -25,10 +26,10 @@ test('Verify that user can use survey link', async t => { // Verify that user can see survey link on any page inside of DB // Browser page await t.expect(browserPage.userSurveyLink.visible).ok('Survey Link is not displayed'); - // update after resolving testcafe Native Automation mode limitations - // await t.click(browserPage.userSurveyLink); - // // Verify that when users click on RI survey, they are redirected to https://www.surveymonkey.com/r/redisinsight - // await Common.checkURL(externalPageLink); + await t.click(browserPage.userSurveyLink); + // Verify that when users click on RI survey, they are redirected to https://www.surveymonkey.com/r/redisinsight + await Common.checkURL(externalPageLink); + await goBackHistory(); // await t.switchToParentWindow(); // Workbench page await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); diff --git a/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts b/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts index 218fc831b8..2c8a8db14b 100644 --- a/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts +++ b/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts @@ -4,6 +4,7 @@ import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { BrowserPage } from '../../../../pageObjects'; +import { goBackHistory } from '../../../../helpers/utils'; const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); @@ -89,12 +90,10 @@ test('Verify that user can see in Command helper and click on new group "JSON", await t.click(browserPage.CommandHelper.cliHelperOutputTitles.withExactText(commandToCheck)); // Verify results of opened command await t.expect(browserPage.CommandHelper.cliHelperTitleArgs.textContent).eql(commandArgumentsToCheck, 'Selected command title not correct'); - // update after resolving testcafe Native Automation mode limitations - // // Click on Read More link for selected command - // await t.click(browserPage.CommandHelper.readMoreButton); - // // Check new opened window page with the correct URL - // await Common.checkURL(externalPageLink); - // await t.switchToParentWindow(); + // Click on Read More link for selected command + await t.click(browserPage.CommandHelper.readMoreButton); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLink); }); test('Verify that user can see in Command helper and click on new group "Search", can choose it and see list of commands in the group', async t => { filteringGroup = 'Search'; @@ -109,12 +108,10 @@ test('Verify that user can see in Command helper and click on new group "Search" await t.click(browserPage.CommandHelper.cliHelperOutputTitles.withExactText(commandToCheck)); // Verify results of opened command await t.expect(browserPage.CommandHelper.cliHelperTitleArgs.textContent).eql(commandArgumentsToCheck, 'Selected command title not correct'); - // update after resolving testcafe Native Automation mode limitations - // // Click on Read More link for selected command - // await t.click(browserPage.CommandHelper.readMoreButton); - // // Check new opened window page with the correct URL - // await Common.checkURL(externalPageLink); - // await t.switchToParentWindow(); + // Click on Read More link for selected command + await t.click(browserPage.CommandHelper.readMoreButton); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLink); }); test('Verify that user can see HyperLogLog title in Command Helper for this command group', async t => { filteringGroup = 'HyperLogLog'; @@ -129,13 +126,10 @@ test('Verify that user can see HyperLogLog title in Command Helper for this comm await t.click(browserPage.CommandHelper.cliHelperOutputTitles.withExactText(commandToCheck)); // Verify results of opened command await t.expect(browserPage.CommandHelper.cliHelperTitleArgs.textContent).eql(commandArgumentsToCheck, 'Selected command title not correct'); - // update after resolving testcafe Native Automation mode limitations - // // Click on Read More link for selected command - // await t.click(browserPage.CommandHelper.readMoreButton); - // // Check new opened window page with the correct URL - // await Common.checkURL(externalPageLink); - // // await t.expect(getPageUrl()).eql(externalPageLink, 'The opened page'); - // await t.switchToParentWindow(); + // Click on Read More link for selected command + await t.click(browserPage.CommandHelper.readMoreButton); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLink); }); test('Verify that user can see all separated groups for AI json file (model, tensor, inference, script)', async t => { filteringGroups = ['Model', 'Script', 'Inference', 'Tensor']; @@ -168,13 +162,12 @@ test('Verify that user can see all separated groups for AI json file (model, ten await t.click(browserPage.CommandHelper.cliHelperOutputTitles.withExactText(commandsToCheck[i])); // Verify results of opened command await t.expect(browserPage.CommandHelper.cliHelperTitleArgs.textContent).eql(commandsArgumentsToCheck[i], 'Selected command title not correct'); - // update after resolving testcafe Native Automation mode limitations - // // Click on Read More link for selected command - // await t.click(browserPage.CommandHelper.readMoreButton); - // // Check new opened window page with the correct URL - // await Common.checkURL(externalPageLinks[i]); - // // Close the window with external link to switch to the application window - // await t.closeWindow(); + // Click on Read More link for selected command + await t.click(browserPage.CommandHelper.readMoreButton); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLinks[i]); + // Close the window with external link to switch to the application window + await goBackHistory(); i++; } }); @@ -192,13 +185,10 @@ test('Verify that user can work with Gears group in Command Helper (RedisGears m await t.click(browserPage.CommandHelper.cliHelperOutputTitles.withExactText(commandToCheck)); // Verify results of opened command await t.expect(browserPage.CommandHelper.cliHelperTitleArgs.textContent).eql(commandArgumentsToCheck, 'Selected command title not correct'); - // update after resolving testcafe Native Automation mode limitations - // // Verify that user can use Read More link for Gears group in Command Helper (RedisGears module) - // await t.click(browserPage.CommandHelper.readMoreButton); - // // Check new opened window page with the correct URL - // await Common.checkURL(externalPageLink); - // // Close the window with external link to switch to the application window - // await t.closeWindow(); + // Verify that user can use Read More link for Gears group in Command Helper (RedisGears module) + await t.click(browserPage.CommandHelper.readMoreButton); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLink); }); test('Verify that user can work with Bloom groups in Command Helper (RedisBloom module)', async t => { filteringGroups = ['Bloom Filter', 'CMS', 'TDigest', 'TopK', 'Cuckoo Filter']; @@ -234,13 +224,12 @@ test('Verify that user can work with Bloom groups in Command Helper (RedisBloom await t.click(browserPage.CommandHelper.cliHelperOutputTitles.withExactText(commandsToCheck[i])); // Verify results of opened command await t.expect(browserPage.CommandHelper.cliHelperTitleArgs.textContent).eql(commandsArgumentsToCheck[i], 'Selected command title not correct'); - // update after resolving testcafe Native Automation mode limitations - // // Verify that user can use Read More link for Bloom, Cuckoo, CMS, TDigest, TopK groups in Command Helper (RedisBloom module). - // await t.click(browserPage.CommandHelper.readMoreButton); - // // Check new opened window page with the correct URL - // await Common.checkURL(externalPageLinks[i]); - // // Close the window with external link to switch to the application window - // await t.closeWindow(); + // Verify that user can use Read More link for Bloom, Cuckoo, CMS, TDigest, TopK groups in Command Helper (RedisBloom module). + await t.click(browserPage.CommandHelper.readMoreButton); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLinks[i]); + // Close the window with external link to switch to the application window + await goBackHistory(); i++; } }); diff --git a/tests/e2e/tests/web/regression/database/github.e2e.ts b/tests/e2e/tests/web/regression/database/github.e2e.ts index bb8eb16940..cbb3036095 100644 --- a/tests/e2e/tests/web/regression/database/github.e2e.ts +++ b/tests/e2e/tests/web/regression/database/github.e2e.ts @@ -24,7 +24,7 @@ fixture `Github functionality` // Delete database await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test.only('Verify that user can work with Github link in the application', async t => { +test('Verify that user can work with Github link in the application', async t => { // Verify that user can see the icon for GitHub reference at the bottom of the left side bar in the List of DBs await t.expect(myRedisDatabasePage.NavigationPanel.githubButton.visible).ok('Github button not found'); //Verify that user can see the icon for GitHub reference at the bottom of the left side bar on the Browser page @@ -33,7 +33,6 @@ test.only('Verify that user can work with Github link in the application', async // Verify that user can see the icon for GitHub reference at the bottom of the left side bar on the Workbench page await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); await t.expect(myRedisDatabasePage.NavigationPanel.githubButton.visible).ok('Github button'); - // update after resolving testcafe Native Automation mode limitations // Verify that when user clicks on Github icon he redirects to the URL: https://github.com/RedisInsight/RedisInsight await t.click(myRedisDatabasePage.NavigationPanel.githubButton); await Common.checkURLContainsText('https://github.com/RedisInsight/RedisInsight'); diff --git a/tests/e2e/tests/web/regression/shortcuts/shortcuts.e2e.ts b/tests/e2e/tests/web/regression/shortcuts/shortcuts.e2e.ts index 686b4c8474..a58099ffa2 100644 --- a/tests/e2e/tests/web/regression/shortcuts/shortcuts.e2e.ts +++ b/tests/e2e/tests/web/regression/shortcuts/shortcuts.e2e.ts @@ -3,10 +3,10 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; import { MyRedisDatabasePage } from '../../../../pageObjects'; import { commonUrl } from '../../../../helpers/conf'; +import { Common } from '../../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); const databaseHelper = new DatabaseHelper(); -// const getPageUrl = ClientFunction(() => window.location.href); fixture `Shortcuts` .meta({ type: 'regression', rte: rte.none }) @@ -31,12 +31,11 @@ test('Verify that user can see a summary of Shortcuts by clicking "Keyboard Shor // Verify that user can close the Shortcuts await t.click(myRedisDatabasePage.ShortcutsPanel.shortcutsCloseButton); await t.expect(myRedisDatabasePage.ShortcutsPanel.shortcutsPanel.exists).notOk('Shortcuts panel is not displayed'); - // update after resolving testcafe Native Automation mode limitations - // // Click on the Release Notes in Help Center - // await t.click(myRedisDatabasePage.NavigationPanel.helpCenterButton); - // await t.click(myRedisDatabasePage.NavigationPanel.HelpCenter.helpCenterReleaseNotesButton); - // // Verify redirected link opening Release Notes in Help Center - // await t.expect(getPageUrl()).eql(link, 'The Release Notes link not correct'); + // Click on the Release Notes in Help Center + await t.click(myRedisDatabasePage.NavigationPanel.helpCenterButton); + await t.click(myRedisDatabasePage.NavigationPanel.HelpCenter.helpCenterReleaseNotesButton); + // Verify redirected link opening Release Notes in Help Center + await Common.checkURL('https://github.com/RedisInsight/RedisInsight/releases'); }); test('Verify that user can see description of the “up” shortcut in the Help Center > Keyboard Shortcuts > Workbench table', async t => { const description = [ diff --git a/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts b/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts index 4360fdd491..71ab05bdbf 100644 --- a/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts @@ -4,6 +4,7 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { MyRedisDatabasePage, SettingsPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneBigConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { Common } from '../../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -11,8 +12,7 @@ const settingsPage = new SettingsPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); -// const getPageUrl = ClientFunction(() => window.location.href); -// const externalPageLink = 'https://redis.io/docs/manual/pipelining/'; +const externalPageLink = 'https://redis.io/docs/manual/pipelining/'; const pipelineValues = ['-5', '5', '4', '20']; const commandForSend = '100 scan 0 match * count 5000'; @@ -41,11 +41,9 @@ test('Verify that user can see the text in settings for pipeline with link', asy // Verify text in setting for pipeline await t.expect(settingsPage.accordionWorkbenchSettings.textContent).contains(pipelineText, 'Text is incorrect'); - // update after resolving testcafe Native Automation mode limitations - // await t.click(settingsPage.pipelineLink); - // // Check new opened window page with the correct URL - // await t.expect(getPageUrl()).eql(externalPageLink, 'The opened page is incorrect'); - // await t.switchToParentWindow(); + await t.click(settingsPage.pipelineLink); + // Check new opened window page with the correct URL + await Common.checkURL(externalPageLink); }); test.skip('Verify that only chosen in pipeline number of commands is loading at the same time in Workbench', async t => { await settingsPage.changeCommandsInPipeline(pipelineValues[1]); diff --git a/tests/e2e/tests/web/smoke/cli/cli.e2e.ts b/tests/e2e/tests/web/smoke/cli/cli.e2e.ts index a3e37b738c..2ff1380b7a 100644 --- a/tests/e2e/tests/web/smoke/cli/cli.e2e.ts +++ b/tests/e2e/tests/web/smoke/cli/cli.e2e.ts @@ -5,6 +5,7 @@ import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { Common } from '../../../../helpers/common'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; +import { goBackHistory, openRedisHomePage } from '../../../../helpers/utils'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -74,15 +75,13 @@ test.skip('Verify that user can use unblocking command', async t => { // Verify that user input is blocked await t.expect(browserPage.Cli.cliCommandInput.exists).notOk('Cli input is still shown'); // Create new window to unblock the client - await t - .openWindow(commonUrl) - .maximizeWindow(); + await openRedisHomePage(); await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); // Open CLI await t.click(browserPage.Cli.cliExpandButton); // Unblock client await t.typeText(browserPage.Cli.cliCommandInput, `client unblock ${clientId}`, { replace: true, paste: true }); await t.pressKey('enter'); - await t.closeWindow(); + await goBackHistory(); await t.expect(browserPage.Cli.cliCommandInput.exists).ok('Cli input is not shown, the client still blocked', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/web/smoke/database/add-db-from-welcome-page.e2e.ts b/tests/e2e/tests/web/smoke/database/add-db-from-welcome-page.e2e.ts index 1535f1338b..77ffaab599 100644 --- a/tests/e2e/tests/web/smoke/database/add-db-from-welcome-page.e2e.ts +++ b/tests/e2e/tests/web/smoke/database/add-db-from-welcome-page.e2e.ts @@ -4,6 +4,7 @@ import { DatabaseHelper } from '../../../../helpers/database'; import { MyRedisDatabasePage, BrowserPage, WelcomePage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { goBackHistory } from '../../../../helpers/utils'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browsePage = new BrowserPage(); @@ -15,7 +16,7 @@ const getPageUrl = ClientFunction(() => window.location.href); const sourcePage = 'https://developer.redis.com/create/from-source/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight'; const dockerPage = 'https://developer.redis.com/create/docker/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight'; const homebrewPage = 'https://developer.redis.com/create/homebrew/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight'; -const promoPage = 'https://redis.com/redis-enterprise-cloud/overview/?utm_source=redisinsight&utm_medium=main&utm_campaign=main'; +const promoPage = 'https://redis.com/cloud/overview/?utm_source=redisinsight&utm_medium=main&utm_campaign=main'; fixture `Add database from welcome page` .meta({ type: 'smoke', rte: rte.standalone }) @@ -36,20 +37,20 @@ test await databaseHelper.addNewStandaloneDatabase(ossStandaloneConfig); await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).ok('The database not added', { timeout: 10000 }); }); -// update after resolving testcafe Native Automation mode limitations -test.skip('Verify that all the links are valid from Welcome page', async t => { + +test('Verify that all the links are valid from Welcome page', async t => { // Verify build from source link await t.click(welcomePage.buildFromSource); await t.expect(getPageUrl()).eql(sourcePage, 'Build from source link is not valid'); - await t.switchToParentWindow(); + await goBackHistory(); // Verify build from docker link await t.click(welcomePage.buildFromDocker); await t.expect(getPageUrl()).eql(dockerPage, 'Build from docker page is not valid'); - await t.switchToParentWindow(); + await goBackHistory(); // Verify build from homebrew link await t.click(welcomePage.buildFromHomebrew); await t.expect(getPageUrl()).eql(homebrewPage, 'Build from homebrew page is not valid'); - await t.switchToParentWindow(); + await goBackHistory(); // Verify promo button link await t.click(welcomePage.tryRedisCloudBtn); await t.expect(getPageUrl()).eql(promoPage, 'Promotion link is not valid'); From dca55b4baaab40a43aa2e2a9eeaf64bc953425fa Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Nov 2023 08:43:17 +0200 Subject: [PATCH 71/96] remove check for not empty ssh passphrase to avoid validation error in case when any logic is missed on FE side and BE receives empty string --- redisinsight/api/src/modules/ssh/models/ssh-options.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/redisinsight/api/src/modules/ssh/models/ssh-options.ts b/redisinsight/api/src/modules/ssh/models/ssh-options.ts index 34ff4cfaf5..a5ad3a8b3c 100644 --- a/redisinsight/api/src/modules/ssh/models/ssh-options.ts +++ b/redisinsight/api/src/modules/ssh/models/ssh-options.ts @@ -67,7 +67,6 @@ export class SshOptions { }) @Expose() @IsString({ always: true }) - @IsNotEmpty() @IsOptional() passphrase?: string; } From ac4b2ad9dddfdc5b36dded829074247988906f11 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Nov 2023 08:48:09 +0200 Subject: [PATCH 72/96] force update file-stream-rotator in the winston-daily-rotate-file to solve bug when user has "(" symbol in username and file rotator fails --- redisinsight/api/package.json | 3 ++- redisinsight/api/yarn.lock | 15 ++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 633f3bb8e8..e228709a41 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -42,7 +42,8 @@ "word-wrap": "1.2.4", "mocha/minimatch": "^3.0.5", "@nestjs/platform-socket.io/socket.io": "^4.7.1", - "**/semver": "^7.5.2" + "**/semver": "^7.5.2", + "winston-daily-rotate-file/**/file-stream-rotator": "^1.0.0" }, "dependencies": { "@nestjs/common": "^9.0.11", diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index 2c28d8aed2..252ab1e1c7 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -3919,12 +3919,10 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-stream-rotator@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz#007019e735b262bb6c6f0197e58e5c87cb96cec3" - integrity sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ== - dependencies: - moment "^2.29.1" +file-stream-rotator@^0.6.1, file-stream-rotator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-stream-rotator/-/file-stream-rotator-1.0.0.tgz#de58379321a1ea6d2938ed5f5a2eff3b7f8b2780" + integrity sha512-qg5mQO7o+vhS7NPqkrkfJS8qqhz0d17Tnewmb5sUTUKwYe27LKaDtbTuRAtQWkBn6jROuFPVIDF5DtckzokFTQ== file-type@^16.5.4: version "16.5.4" @@ -6201,11 +6199,6 @@ mocha@^8.4.0: yargs-parser "20.2.4" yargs-unparser "2.0.0" -moment@^2.29.1: - version "2.29.4" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" - integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== - ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" From 0f725f739ceaaff13e988dc4e9822140c9895170 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Tue, 21 Nov 2023 10:11:17 +0100 Subject: [PATCH 73/96] fix part the second part of the tests --- tests/e2e/helpers/utils.ts | 2 +- .../browser/search-capabilities.e2e.ts | 22 +++++++++---------- .../database/import-databases.e2e.ts | 3 +-- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/tests/e2e/helpers/utils.ts b/tests/e2e/helpers/utils.ts index abd81fdd6a..b143f53aa0 100644 --- a/tests/e2e/helpers/utils.ts +++ b/tests/e2e/helpers/utils.ts @@ -2,4 +2,4 @@ import { ClientFunction } from 'testcafe'; import { commonUrl } from './conf'; export const goBackHistory = ClientFunction(() => window.history.back()); -export const openRedisHomePage = ClientFunction(() => window.open(commonUrl)); +export const openRedisHomePage = ClientFunction(() => window.open(commonUrl, '_blank')); diff --git a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts index 75e76fceae..ccb9670878 100644 --- a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts @@ -12,6 +12,7 @@ import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; +import { goBackHistory } from '../../../../helpers/utils'; const browserPage = new BrowserPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -154,16 +155,14 @@ test await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('No RediSearch module message', async t => { const noRedisearchMessage = 'RediSearch is not available for this database'; - // const externalPageLink = 'https://redis.com/try-free/?utm_source=redisinsight&utm_medium=app&utm_campaign=redisinsight_browser_search'; + const externalPageLink = 'https://redis.com/try-free/?utm_source=redisinsight&utm_medium=app&utm_campaign=redisinsight_browser_search'; await t.click(browserPage.redisearchModeBtn); // Verify that user can see message in the dialog when he doesn't have RediSearch module await t.expect(browserPage.noReadySearchDialogTitle.textContent).contains(noRedisearchMessage, 'Invalid text in no redisearch popover'); - // update after resolving testcafe Native Automation mode limitations - // // Verify that user can navigate by link to create a Redis db - // await t.click(browserPage.redisearchFreeLink); - // await Common.checkURL(externalPageLink); - // await t.switchToParentWindow(); + // Verify that user can navigate by link to create a Redis db + await t.click(browserPage.redisearchFreeLink); + await Common.checkURL(externalPageLink); }); test .before(async() => { @@ -173,7 +172,7 @@ test await browserPage.Cli.sendCommandInCli(`FT.DROPINDEX ${indexName}`); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneBigConfig); })('Index creation', async t => { - // const createIndexLink = 'https://redis.io/commands/ft.create/'; + const createIndexLink = 'https://redis.io/commands/ft.create/'; // Verify that user can cancel index creation await t.click(browserPage.redisearchModeBtn); @@ -188,11 +187,10 @@ test await t.click(browserPage.selectIndexDdn); await t.click(browserPage.createIndexBtn); await t.expect(browserPage.newIndexPanel.exists).ok('New Index panel is not displayed'); - // update after resolving testcafe Native Automation mode limitations - // // Verify that user can see a link to create a profound index and navigate - // await t.click(browserPage.newIndexPanel.find('a')); - // await Common.checkURL(createIndexLink); - // await t.switchToParentWindow(); + // Verify that user can see a link to create a profound index and navigate + await t.click(browserPage.newIndexPanel.find('a')); + await Common.checkURL(createIndexLink); + await goBackHistory(); // Verify that user can create an index with multiple prefixes await t.click(browserPage.indexNameInput); diff --git a/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts index 1f111a0fc9..4ac60774e9 100644 --- a/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts @@ -125,8 +125,7 @@ test.before(async() => { await t.click(myRedisDatabasePage.closeDialogBtn); await t.click(myRedisDatabasePage.importDatabasesBtn); await t.setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [rdmData.path]); - // update after resolving testcafe Native Automation mode limitations - // await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(fileNames.rdmFullJson, 'Filename not displayed in import input'); + await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(fileNames.rdmFullJson, 'Filename not displayed in import input'); // Click on remove button await t.click(myRedisDatabasePage.removeImportedFileBtn); await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(defaultText, 'File not removed from import input'); From 4d7aa60b4123a941dfa247c2f0872554f90651dc Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Tue, 21 Nov 2023 11:23:28 +0100 Subject: [PATCH 74/96] fix for faild tests --- .../web/critical-path/browser/search-capabilities.e2e.ts | 6 ++++-- .../e2e/tests/web/regression/cli/cli-command-helper.e2e.ts | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts index ccb9670878..6e5d4d9cfb 100644 --- a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts @@ -76,7 +76,7 @@ test await t.hover(browserPage.redisearchModeBtn); await t.expect(browserPage.tooltip.textContent).contains(redisearchModeTooltipText, 'Invalid text in redisearch mode tooltip'); - // Verify that user see the "Select an index" message when he switch to Search + // Verify that user see the "Select an index" message when he switches to Search await t.click(browserPage.redisearchModeBtn); await t.expect(browserPage.keyListTable.textContent).contains(notSelectedIndexText, 'Select an index message not displayed'); @@ -113,7 +113,7 @@ test await verifyKeysDisplayingInTheList(keyNames, true); await t.expect(browserPage.getKeySelectorByName(keyName).exists).notOk('Key without index displayed after search'); - // Verify that user see the database scanned when he switch to Pattern search mode + // Verify that user see the database scanned when he switches to Pattern search mode await t.click(browserPage.patternModeBtn); await t.click(browserPage.browserViewButton); await verifyKeysDisplayingInTheList(keyNames, true); @@ -193,6 +193,8 @@ test await goBackHistory(); // Verify that user can create an index with multiple prefixes + await t.click(browserPage.selectIndexDdn); + await t.click(browserPage.createIndexBtn); await t.click(browserPage.indexNameInput); await t.typeText(browserPage.indexNameInput, indexName); await t.click(browserPage.prefixFieldInput); diff --git a/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts b/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts index 2c8a8db14b..9aa89d8181 100644 --- a/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts +++ b/tests/e2e/tests/web/regression/cli/cli-command-helper.e2e.ts @@ -168,6 +168,7 @@ test('Verify that user can see all separated groups for AI json file (model, ten await Common.checkURL(externalPageLinks[i]); // Close the window with external link to switch to the application window await goBackHistory(); + await t.click(browserPage.CommandHelper.expandCommandHelperButton); i++; } }); @@ -230,6 +231,7 @@ test('Verify that user can work with Bloom groups in Command Helper (RedisBloom await Common.checkURL(externalPageLinks[i]); // Close the window with external link to switch to the application window await goBackHistory(); + await t.click(browserPage.CommandHelper.expandCommandHelperButton); i++; } }); From e2b9aacac8a817de2055012afd96e0b6d5cbc305 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Nov 2023 12:38:31 +0200 Subject: [PATCH 75/96] add connection retry on create + wait until "end" to determine that connection was lost --- redisinsight/api/config/default.ts | 2 +- .../api/src/constants/error-messages.ts | 2 + .../database/providers/database.factory.ts | 6 +-- .../modules/redis/redis-connection.factory.ts | 38 ++++++++++++++----- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index a7e39ef64b..7346f1172f 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -77,7 +77,7 @@ export default { redis_clients: { idleSyncInterval: parseInt(process.env.CLIENTS_IDLE_SYNC_INTERVAL, 10) || 1000 * 60 * 60, // 1hr maxIdleThreshold: parseInt(process.env.CLIENTS_MAX_IDLE_THRESHOLD, 10) || 1000 * 60 * 60, // 1hr - retryTimes: parseInt(process.env.CLIENTS_RETRY_TIMES, 10) || 5, + retryTimes: parseInt(process.env.CLIENTS_RETRY_TIMES, 10) || 3, retryDelay: parseInt(process.env.CLIENTS_RETRY_DELAY, 10) || 500, maxRetriesPerRequest: parseInt(process.env.CLIENTS_MAX_RETRIES_PER_REQUEST, 10) || 1, }, diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 797ea2fc26..e87cf6357e 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -16,6 +16,8 @@ export default { CONNECTION_TIMEOUT: 'The connection has timed out, please check the connection details.', SERVER_CLOSED_CONNECTION: 'Server closed the connection.', + UNABLE_TO_ESTABLISH_CONNECTION: 'Unable to establish connection.', + RECONNECTING_TO_DATABASE: 'Reconnecting to the redis database.', AUTHENTICATION_FAILED: () => 'Failed to authenticate, please check the username or password.', INCORRECT_DATABASE_URL: (url) => `Could not connect to ${url}, please check the connection details.`, INCORRECT_CERTIFICATES: (url) => `Could not connect to ${url}, please check the CA or Client certificate.`, diff --git a/redisinsight/api/src/modules/database/providers/database.factory.ts b/redisinsight/api/src/modules/database/providers/database.factory.ts index 82d07fffa9..ec32583f58 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.ts @@ -38,7 +38,7 @@ export class DatabaseFactory { context: ClientContext.Common, }, database, - { useRetry: false }, + { useRetry: true }, ); if (await this.databaseInfoProvider.isSentinel(client)) { @@ -109,7 +109,7 @@ export class DatabaseFactory { context: ClientContext.Common, }, model, - { useRetry: false }, + { useRetry: true }, ); // todo: rethink @@ -157,7 +157,7 @@ export class DatabaseFactory { context: ClientContext.Common, }, model, - { useRetry: false }, + { useRetry: true }, ); model.connectionType = ConnectionType.SENTINEL; diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index 014275b6a9..a8a301ea7b 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -174,6 +174,8 @@ export class RedisConnectionFactory { return await new Promise((resolve, reject) => { try { + let lastError: Error; + if (tnl) { tnl.on('error', (error) => { reject(error); @@ -194,18 +196,20 @@ export class RedisConnectionFactory { }); connection.on('error', (e): void => { this.logger.error('Failed connection to the redis database.', e); - reject(e); + lastError = e; }); connection.on('end', (): void => { - this.logger.error(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION); - reject(new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); + reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); }); connection.on('ready', (): void => { + lastError = null; this.logger.log('Successfully connected to the redis database'); resolve(connection); }); connection.on('reconnecting', (): void => { - this.logger.log('Reconnecting to the redis database'); + lastError = null; + this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); }); } catch (e) { reject(e); @@ -236,6 +240,8 @@ export class RedisConnectionFactory { return new Promise((resolve, reject) => { try { + let lastError: Error; + const cluster = new Cluster([{ host: database.host, port: database.port, @@ -244,16 +250,21 @@ export class RedisConnectionFactory { }); cluster.on('error', (e): void => { this.logger.error('Failed connection to the redis oss cluster', e); - reject(!isEmpty(e.lastNodeError) ? e.lastNodeError : e); + lastError = !isEmpty(e.lastNodeError) ? e.lastNodeError : e; }); cluster.on('end', (): void => { - this.logger.error(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION); - reject(new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); + reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); }); cluster.on('ready', (): void => { + lastError = null; this.logger.log('Successfully connected to the redis oss cluster.'); resolve(cluster); }); + cluster.on('reconnecting', (): void => { + lastError = null; + this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); + }); } catch (e) { reject(e); } @@ -275,19 +286,26 @@ export class RedisConnectionFactory { return new Promise((resolve, reject) => { try { + let lastError: Error; + const client = new Redis(config); client.on('error', (e): void => { this.logger.error('Failed connection to the redis oss sentinel', e); - reject(e); + lastError = e; }); client.on('end', (): void => { - this.logger.error(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION); - reject(new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); + reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); }); client.on('ready', (): void => { + lastError = null; this.logger.log('Successfully connected to the redis oss sentinel.'); resolve(client); }); + client.on('reconnecting', (): void => { + lastError = null; + this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); + }); } catch (e) { reject(e); } From e1238184382df57bb35580bcf71947f6ac1a9f5b Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Tue, 21 Nov 2023 12:33:26 +0100 Subject: [PATCH 76/96] fix for regression test --- .../e2e/tests/web/critical-path/url-handling/url-handling.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/tests/web/critical-path/url-handling/url-handling.e2e.ts b/tests/e2e/tests/web/critical-path/url-handling/url-handling.e2e.ts index a01a4eaf28..7e2840e65a 100644 --- a/tests/e2e/tests/web/critical-path/url-handling/url-handling.e2e.ts +++ b/tests/e2e/tests/web/critical-path/url-handling/url-handling.e2e.ts @@ -42,6 +42,7 @@ test await t.navigateTo(generateLink(connectUrlParams)); await t.expect(myRedisDatabasePage.AddRedisDatabase.disabledDatabaseInfo.nth(0).getAttribute('title')).contains(host, 'Wrong host value'); await t.expect(myRedisDatabasePage.AddRedisDatabase.disabledDatabaseInfo.nth(1).getAttribute('title')).contains(port, 'Wrong port value'); + await t.wait(5_000); await t.click(myRedisDatabasePage.AddRedisDatabase.addRedisDatabaseButton); // wait for db is added await t.wait(10_000); From f1f1d567556a4e59490c0791f014cece2fe10bf2 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Tue, 21 Nov 2023 14:11:54 +0100 Subject: [PATCH 77/96] #RI-5163 - fix bulk action disconnection flow, monitor --- .../BulkActionsConfig.spec.tsx | 5 +- .../bulk-actions-config/BulkActionsConfig.tsx | 20 +-- .../monitor-config/MonitorConfig.spec.tsx | 31 ++++- .../monitor-config/MonitorConfig.tsx | 120 +++++++++++------- redisinsight/ui/src/constants/bulkActions.ts | 1 + .../BulkActionsInfo/BulkActionsInfo.spec.tsx | 8 +- .../BulkActionsInfo/BulkActionsInfo.tsx | 8 +- .../browser/components/bulk-actions/utils.ts | 1 + .../ui/src/slices/browser/bulkActions.ts | 7 + .../slices/tests/browser/bulkActions.spec.ts | 38 +++++- 10 files changed, 167 insertions(+), 72 deletions(-) diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx index 2d7742da38..5b3edaf369 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx @@ -4,13 +4,13 @@ import React from 'react' import MockedSocket from 'socket.io-mock' import socketIO from 'socket.io-client' import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' -import { BulkActionsServerEvent, BulkActionsType, SocketEvent } from 'uiSrc/constants' +import { BulkActionsServerEvent, BulkActionsStatus, BulkActionsType, SocketEvent } from 'uiSrc/constants' import { bulkActionsDeleteSelector, bulkActionsSelector, disconnectBulkDeleteAction, setBulkActionConnected, - setBulkDeleteLoading + setBulkDeleteLoading, setDeleteOverviewStatus } from 'uiSrc/slices/browser/bulkActions' import BulkActionsConfig from './BulkActionsConfig' @@ -110,6 +110,7 @@ describe('BulkActionsConfig', () => { const afterRenderActions = [ setBulkActionConnected(true), setBulkDeleteLoading(true), + setDeleteOverviewStatus(BulkActionsStatus.Disconnected), disconnectBulkDeleteAction(), ] expect(store.getActions()).toEqual([...afterRenderActions]) diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index 410bb5458a..772fb2f377 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -11,6 +11,7 @@ import { setDeleteOverview, setBulkActionsInitialState, bulkActionsDeleteSelector, + setDeleteOverviewStatus, } from 'uiSrc/slices/browser/bulkActions' import { getBaseApiUrl, Nullable } from 'uiSrc/utils' import { sessionStorageService } from 'uiSrc/services' @@ -21,11 +22,7 @@ import { BrowserStorageItem, BulkActionsServerEvent, BulkActionsStatus, BulkActi import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { CustomHeaders } from 'uiSrc/constants/api' -interface IProps { - retryDelay?: number -} - -const BulkActionsConfig = ({ retryDelay = 5000 } : IProps) => { +const BulkActionsConfig = () => { const { id: instanceId = '', db } = useSelector(connectedInstanceSelector) const { isConnected } = useSelector(bulkActionsSelector) const { isActionTriggered: isDeleteTriggered } = useSelector(bulkActionsDeleteSelector) @@ -60,11 +57,8 @@ const BulkActionsConfig = ({ retryDelay = 5000 } : IProps) => { // Catch disconnect socketRef.current?.on(SocketEvent.Disconnect, () => { - if (retryDelay) { - retryTimer = setTimeout(handleDisconnect, retryDelay) - } else { - handleDisconnect() - } + dispatch(setDeleteOverviewStatus(BulkActionsStatus.Disconnected)) + handleDisconnect() }) }, [instanceId, isDeleteTriggered]) @@ -147,10 +141,8 @@ const BulkActionsConfig = ({ retryDelay = 5000 } : IProps) => { const onBulkDeleteAborted = (data: any) => { dispatch(setBulkDeleteLoading(false)) sessionStorageService.set(BrowserStorageItem.bulkActionDeleteId, '') - - if (data.status === 'aborted') { - dispatch(setDeleteOverview(data)) - } + dispatch(setDeleteOverview(data)) + handleDisconnect() } useEffect(() => { diff --git a/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx b/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx index 5d0f8cc246..0b4e0fcfd6 100644 --- a/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx +++ b/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx @@ -9,7 +9,7 @@ import { pauseMonitor, setSocket, stopMonitor, - lockResume + lockResume, setLogFileId, setStartTimestamp } from 'uiSrc/slices/cli/monitor' import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' import { MonitorEvent, SocketEvent } from 'uiSrc/constants' @@ -69,26 +69,47 @@ describe('MonitorConfig', () => { it(`should emit ${MonitorEvent.Monitor} event`, () => { const monitorSelectorMock = jest.fn().mockReturnValue({ isRunning: true, + isSaveToFile: true }) monitorSelector.mockImplementation(monitorSelectorMock) const { unmount } = render() - socket.on(MonitorEvent.MonitorData, (data: []) => { - expect(data).toEqual(['message1', 'message2']) + socket.socketClient.on(MonitorEvent.Monitor, (data: any) => { + expect(data).toEqual({ logFileId: expect.any(String) }) }) - socket.socketClient.emit(MonitorEvent.MonitorData, ['message1', 'message2']) + socket.socketClient.emit(SocketEvent.Connect) const afterRenderActions = [ setSocket(socket), - setMonitorLoadingPause(true) + setMonitorLoadingPause(true), + setLogFileId(expect.any(String)), + setStartTimestamp(expect.any(Number)) ] expect(store.getActions()).toEqual([...afterRenderActions]) unmount() }) + it(`should not emit ${MonitorEvent.Monitor} event when paused`, () => { + const monitorSelectorMock = jest.fn().mockReturnValue({ + isRunning: true, + isPaused: true + }) + monitorSelector.mockImplementation(monitorSelectorMock) + + const { unmount } = render() + const mockedMonitorEvent = jest.fn() + + socket.socketClient.on(MonitorEvent.Monitor, mockedMonitorEvent) + socket.socketClient.emit(SocketEvent.Connect) + + expect(mockedMonitorEvent).not.toBeCalled() + + unmount() + }) + it('monitor should catch Exception', () => { const { unmount } = render() diff --git a/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx b/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx index f61fc6ad21..8649203560 100644 --- a/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx +++ b/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx @@ -1,7 +1,7 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' import { debounce } from 'lodash' -import { io } from 'socket.io-client' +import { io, Socket } from 'socket.io-client' import { v4 as uuidv4 } from 'uuid' import { @@ -16,7 +16,7 @@ import { setLogFileId, pauseMonitor, lockResume } from 'uiSrc/slices/cli/monitor' -import { getBaseApiUrl } from 'uiSrc/utils' +import { getBaseApiUrl, Nullable } from 'uiSrc/utils' import { MonitorErrorMessages, MonitorEvent, SocketErrors, SocketEvent } from 'uiSrc/constants' import { IMonitorDataPayload } from 'uiSrc/slices/interfaces' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' @@ -26,12 +26,18 @@ import { IMonitorData } from 'apiSrc/modules/profiler/interfaces/monitor-data.in import ApiStatusCode from '../../constants/apiStatusCode' interface IProps { - retryDelay?: number; + retryDelay?: number } const MonitorConfig = ({ retryDelay = 15000 } : IProps) => { const { id: instanceId = '' } = useSelector(connectedInstanceSelector) const { socket, isRunning, isPaused, isSaveToFile, isMinimizedMonitor, isShowMonitor } = useSelector(monitorSelector) + const socketRef = useRef>(null) + const logFileIdRef = useRef() + const timestampRef = useRef() + const retryTimerRef = useRef() + const payloadsRef = useRef([]) + const dispatch = useDispatch() const setNewItems = debounce((items, onSuccess?) => { @@ -52,56 +58,28 @@ const MonitorConfig = ({ retryDelay = 15000 } : IProps) => { if (!isRunning || !instanceId || socket?.connected) { return } - const logFileId = `_redis_${uuidv4()}` - const timestamp = Date.now() - let retryTimer: NodeJS.Timer + + logFileIdRef.current = `_redis_${uuidv4()}` + timestampRef.current = Date.now() // Create SocketIO connection to instance by instanceId - const newSocket = io(`${getBaseApiUrl()}/monitor`, { + socketRef.current = io(`${getBaseApiUrl()}/monitor`, { forceNew: true, query: { instanceId }, extraHeaders: { [CustomHeaders.WindowId]: window.windowId || '' }, rejectUnauthorized: false, }) - dispatch(setSocket(newSocket)) - let payloads: IMonitorDataPayload[] = [] - - const handleMonitorEvents = () => { - dispatch(setMonitorLoadingPause(false)) - newSocket.on(MonitorEvent.MonitorData, (payload: IMonitorData[]) => { - payloads = payloads.concat(payload) - - // set batch of payloads and then clear batch - setNewItems(payloads, () => { - payloads.length = 0 - // reset all timings after items were changed - setNewItems.cancel() - }) - }) - } + dispatch(setSocket(socketRef.current)) const handleDisconnect = () => { - newSocket.removeAllListeners() + socketRef.current?.removeAllListeners() dispatch(pauseMonitor()) dispatch(stopMonitor()) dispatch(lockResume()) } - newSocket.on(SocketEvent.Connect, () => { - // Trigger Monitor event - clearTimeout(retryTimer) - - dispatch(setLogFileId(logFileId)) - dispatch(setStartTimestamp(timestamp)) - newSocket.emit( - MonitorEvent.Monitor, - { logFileId: isSaveToFile ? logFileId : null }, - handleMonitorEvents - ) - }) - // Catch exceptions - newSocket.on(MonitorEvent.Exception, (payload) => { + socketRef.current?.on(MonitorEvent.Exception, (payload) => { if (payload.status === ApiStatusCode.Forbidden) { handleDisconnect() dispatch(setError(MonitorErrorMessages.NoPerm)) @@ -109,26 +87,51 @@ const MonitorConfig = ({ retryDelay = 15000 } : IProps) => { return } - payloads.push({ isError: true, time: `${Date.now()}`, ...payload }) - setNewItems(payloads, () => { payloads.length = 0 }) + payloadsRef.current.push({ isError: true, time: `${Date.now()}`, ...payload }) + setNewItems(payloadsRef.current, () => { payloads.length = 0 }) dispatch(pauseMonitor()) }) // Catch disconnect - newSocket.on(SocketEvent.Disconnect, () => { + socketRef.current?.on(SocketEvent.Disconnect, () => { if (retryDelay) { - retryTimer = setTimeout(handleDisconnect, retryDelay) + retryTimerRef.current = setTimeout(handleDisconnect, retryDelay) } else { handleDisconnect() } }) // Catch connect error - newSocket.on(SocketEvent.ConnectionError, (error) => { - payloads.push({ isError: true, time: `${Date.now()}`, message: getErrorMessage(error) }) - setNewItems(payloads, () => { payloads.length = 0 }) + socketRef.current?.on(SocketEvent.ConnectionError, (error) => { + payloadsRef.current.push({ isError: true, time: `${Date.now()}`, message: getErrorMessage(error) }) + setNewItems(payloadsRef.current, () => { payloadsRef.current.length = 0 }) + }) + }, [instanceId, isRunning, isPaused]) + + useEffect(() => { + if (!isRunning) { + return + } + + socketRef.current?.removeAllListeners(SocketEvent.Connect) + socketRef.current?.on(SocketEvent.Connect, () => { + // Trigger Monitor event + clearTimeout(retryTimerRef.current!) + dispatch(setLogFileId(logFileIdRef.current)) + dispatch(setStartTimestamp(timestampRef.current)) + if (!isPaused) { + subscribeMonitorEvents() + } }) - }, [instanceId, isRunning, isSaveToFile]) + }, [isRunning, isPaused]) + + useEffect(() => { + if (!isRunning || isPaused || !socketRef.current?.connected) { + return + } + + subscribeMonitorEvents() + }, [isRunning, isPaused]) useEffect(() => { if (!isRunning) return @@ -150,6 +153,29 @@ const MonitorConfig = ({ retryDelay = 15000 } : IProps) => { } }, [socket, isRunning, isShowMonitor, isMinimizedMonitor]) + const subscribeMonitorEvents = () => { + socketRef.current?.removeAllListeners(MonitorEvent.MonitorData) + socketRef.current?.emit( + MonitorEvent.Monitor, + { logFileId: isSaveToFile ? logFileIdRef.current : null }, + handleMonitorEvents + ) + } + + const handleMonitorEvents = () => { + dispatch(setMonitorLoadingPause(false)) + socketRef.current?.on(MonitorEvent.MonitorData, (payload: IMonitorData[]) => { + payloadsRef.current = payloadsRef.current.concat(payload) + + // set batch of payloads and then clear batch + setNewItems(payloadsRef.current, () => { + payloadsRef.current.length = 0 + // reset all timings after items were changed + setNewItems.cancel() + }) + }) + } + return null } diff --git a/redisinsight/ui/src/constants/bulkActions.ts b/redisinsight/ui/src/constants/bulkActions.ts index 5a2d6b3945..7a8cea7403 100644 --- a/redisinsight/ui/src/constants/bulkActions.ts +++ b/redisinsight/ui/src/constants/bulkActions.ts @@ -20,6 +20,7 @@ export enum BulkActionsStatus { Completed = 'completed', Failed = 'failed', Aborted = 'aborted', + Disconnected = 'disconnected' } export const MAX_BULK_ACTION_ERRORS_LENGTH = 500 diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.spec.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.spec.tsx index ab9f512d99..6fd69b43e7 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.spec.tsx @@ -1,6 +1,6 @@ import React from 'react' import { mock } from 'ts-mockito' -import { KeyTypes } from 'uiSrc/constants' +import { BulkActionsStatus, KeyTypes } from 'uiSrc/constants' import { render, screen } from 'uiSrc/utils/test-utils' import BulkActionsInfo, { Props } from './BulkActionsInfo' @@ -25,4 +25,10 @@ describe('BulkActionsInfo', () => { expect(screen.queryByTestId('bulk-actions-info-filter')).not.toBeInTheDocument() }) + + it('should show connection lost when status is disconnect', () => { + render() + + expect(screen.getByTestId('bulk-status-disconnected')).toHaveTextContent('Connection Lost') + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx index c46b95c658..141ca2e9e8 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx @@ -7,6 +7,7 @@ import { getApproximatePercentage, Maybe, Nullable } from 'uiSrc/utils' import Divider from 'uiSrc/components/divider/Divider' import { BulkActionsStatus, KeyTypes } from 'uiSrc/constants' import GroupBadge from 'uiSrc/components/group-badge/GroupBadge' +import { isProcessedBulkAction } from 'uiSrc/pages/browser/components/bulk-actions/utils' import styles from './styles.module.scss' export interface Props { @@ -46,7 +47,7 @@ const BulkActionsInfo = (props: Props) => {
)} - {!isUndefined(status) && status !== BulkActionsStatus.Completed && status !== BulkActionsStatus.Aborted && ( + {!isUndefined(status) && !isProcessedBulkAction(status) && ( In progress: {` ${getApproximatePercentage(total, scanned)}`} @@ -62,6 +63,11 @@ const BulkActionsInfo = (props: Props) => { Action completed )} + {status === BulkActionsStatus.Disconnected && ( + + Connection Lost: {getApproximatePercentage(total, scanned)} + + )}
{loading && ( diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/utils.ts b/redisinsight/ui/src/pages/browser/components/bulk-actions/utils.ts index 04ee7430d0..74bca9c106 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/utils.ts +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/utils.ts @@ -9,3 +9,4 @@ export const isProcessedBulkAction = (status?: BulkActionsStatus) => status === BulkActionsStatus.Completed || status === BulkActionsStatus.Aborted || status === BulkActionsStatus.Failed + || status === BulkActionsStatus.Disconnected diff --git a/redisinsight/ui/src/slices/browser/bulkActions.ts b/redisinsight/ui/src/slices/browser/bulkActions.ts index 6678bac398..d79db8cdca 100644 --- a/redisinsight/ui/src/slices/browser/bulkActions.ts +++ b/redisinsight/ui/src/slices/browser/bulkActions.ts @@ -86,6 +86,12 @@ const bulkActionsSlice = createSlice({ } }, + setDeleteOverviewStatus: (state, { payload }) => { + if (state.bulkDelete.overview) { + state.bulkDelete.overview.status = payload + } + }, + disconnectBulkDeleteAction: (state) => { state.bulkDelete.loading = false state.bulkDelete.isActionTriggered = false @@ -127,6 +133,7 @@ export const { disconnectBulkDeleteAction, toggleBulkDeleteActionTriggered, setDeleteOverview, + setDeleteOverviewStatus, setBulkActionsInitialState, bulkDeleteSuccess, bulkUpload, diff --git a/redisinsight/ui/src/slices/tests/browser/bulkActions.spec.ts b/redisinsight/ui/src/slices/tests/browser/bulkActions.spec.ts index 645de24d71..80bd4e4155 100644 --- a/redisinsight/ui/src/slices/tests/browser/bulkActions.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/bulkActions.spec.ts @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash' import { AxiosError } from 'axios' -import { BulkActionsType } from 'uiSrc/constants' +import { BulkActionsStatus, BulkActionsType } from 'uiSrc/constants' import reducer, { bulkActionsSelector, initialState, @@ -19,7 +19,7 @@ import reducer, { bulkUpload, bulkUploadSuccess, bulkUploadFailed, - bulkUploadDataAction, + bulkUploadDataAction, setDeleteOverviewStatus, } from 'uiSrc/slices/browser/bulkActions' import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' import { apiService } from 'uiSrc/services' @@ -271,6 +271,40 @@ describe('bulkActions slice', () => { }) }) + describe('setDeleteOverviewStatus', () => { + it('should properly set state', () => { + // Arrange + const currentState = { + ...initialState, + bulkDelete: { + ...initialState.bulkDelete, + overview: { + id: 1, + databaseId: '1', + duration: 300, + status: 'inprogress', + type: BulkActionsType.Delete, + summary: { processed: 1, succeed: 1, failed: 0, errors: [] }, + } + } + } + + const overviewState = { + ...currentState.bulkDelete.overview, + status: BulkActionsStatus.Disconnected + } + + // Act + const nextState = reducer(currentState, setDeleteOverviewStatus(BulkActionsStatus.Disconnected)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { bulkActions: nextState }, + }) + expect(bulkActionsDeleteOverviewSelector(rootState)).toEqual(overviewState) + }) + }) + describe('disconnectBulkDeleteAction', () => { it('should properly set state', () => { // Arrange From fe6ad91a7e9b60b78c54b1cf44adb6c602e73034 Mon Sep 17 00:00:00 2001 From: mariasergeenko Date: Tue, 21 Nov 2023 14:52:18 +0100 Subject: [PATCH 78/96] remove redundant method --- tests/e2e/helpers/utils.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/helpers/utils.ts b/tests/e2e/helpers/utils.ts index b143f53aa0..addb1a66ad 100644 --- a/tests/e2e/helpers/utils.ts +++ b/tests/e2e/helpers/utils.ts @@ -1,5 +1,3 @@ import { ClientFunction } from 'testcafe'; -import { commonUrl } from './conf'; export const goBackHistory = ClientFunction(() => window.history.back()); -export const openRedisHomePage = ClientFunction(() => window.open(commonUrl, '_blank')); From 39741c62ada97c02e6019a273848b2aa05e0b5a2 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Nov 2023 16:27:14 +0200 Subject: [PATCH 79/96] fix UTests --- .../redis/redis-connection.factory.spec.ts | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts index 18cc849767..6cf370dca5 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts @@ -87,11 +87,21 @@ describe('RedisConnectionFactory', () => { process.nextTick(() => mockClient.emit('ready')); }); - it('should fail to create standalone connection', (done) => { + it('should successfully create standalone client even with error event emited', (done) => { + service.createStandaloneConnection(mockClientMetadata, mockDatabaseWithTlsAuth, { useRetry: true }) + .then(checkClient(done, mockClient)); + process.nextTick(() => mockClient.emit('error', mockError)); + process.nextTick(() => mockClient.emit('ready')); + }); + + it('should fail to create standalone with last error', (done) => { service.createStandaloneConnection(mockClientMetadata, mockDatabaseWithTlsAuth, {}) .catch(checkError(done)); + process.nextTick(() => mockClient.emit('error', new Error('1'))); + process.nextTick(() => mockClient.emit('error', new Error('2'))); process.nextTick(() => mockClient.emit('error', mockError)); + process.nextTick(() => mockClient.emit('end')); }); it('should handle sync error during standalone client creation', (done) => { @@ -113,11 +123,22 @@ describe('RedisConnectionFactory', () => { process.nextTick(() => mockCluster.emit('ready')); }); - it('should fail to create cluster connection', (done) => { + it('should successfully create cluster client and not fail even when error emited', (done) => { + service.createClusterConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth, {}) + .then(checkClient(done, mockCluster)); + + process.nextTick(() => mockCluster.emit('error', mockError)); + process.nextTick(() => mockCluster.emit('ready')); + }); + + it('should fail to create cluster connection with last error', (done) => { service.createClusterConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth, {}) .catch(checkError(done)); + process.nextTick(() => mockCluster.emit('error', new Error('1'))); + process.nextTick(() => mockCluster.emit('error', new Error('2'))); process.nextTick(() => mockCluster.emit('error', mockError)); + process.nextTick(() => mockCluster.emit('end')); }); it('should handle sync error during cluster client creation', (done) => { @@ -138,11 +159,22 @@ describe('RedisConnectionFactory', () => { process.nextTick(() => mockClient.emit('ready')); }); - it('should fail to create sentinel connection', (done) => { + it('should successfully create sentinel client and not fail even when error emited', (done) => { + service.createSentinelConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth, { useRetry: true }) + .then(checkClient(done, mockClient)); + + process.nextTick(() => mockClient.emit('error', mockError)); + process.nextTick(() => mockClient.emit('ready')); + }); + + it('should fail to create sentinel connection with last error', (done) => { service.createSentinelConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth, {}) .catch(checkError(done)); + process.nextTick(() => mockClient.emit('error', new Error('1'))); + process.nextTick(() => mockClient.emit('error', new Error('2'))); process.nextTick(() => mockClient.emit('error', mockError)); + process.nextTick(() => mockClient.emit('end')); }); it('should handle sync error during sentinel client creation', (done) => { From 34236e9bc910420d04a56548c97e8f784dfcec94 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 22 Nov 2023 11:56:33 +0200 Subject: [PATCH 80/96] fix validation error on UI side + rollback BE changes --- redisinsight/api/src/modules/ssh/models/ssh-options.ts | 1 + redisinsight/ui/src/utils/comparisons/diff.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/ssh/models/ssh-options.ts b/redisinsight/api/src/modules/ssh/models/ssh-options.ts index a5ad3a8b3c..34ff4cfaf5 100644 --- a/redisinsight/api/src/modules/ssh/models/ssh-options.ts +++ b/redisinsight/api/src/modules/ssh/models/ssh-options.ts @@ -67,6 +67,7 @@ export class SshOptions { }) @Expose() @IsString({ always: true }) + @IsNotEmpty() @IsOptional() passphrase?: string; } diff --git a/redisinsight/ui/src/utils/comparisons/diff.ts b/redisinsight/ui/src/utils/comparisons/diff.ts index 5f7fcc4736..b154ed3a47 100644 --- a/redisinsight/ui/src/utils/comparisons/diff.ts +++ b/redisinsight/ui/src/utils/comparisons/diff.ts @@ -23,7 +23,7 @@ export const getFormUpdates = (obj1: UnknownObject = {}, obj2: UnknownObject = { obj1, (result: UnknownObject, value, key) => { if (isObject(value)) { - const diff = getFormUpdates(value, obj2[key]) + const diff = getFormUpdates(value, obj2[key] || {}) if (Object.keys(diff).length) { result[key] = diff From 6d8637cfcc31401769aa0b794293ca37cfb9d570 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 23 Nov 2023 10:19:36 +0200 Subject: [PATCH 81/96] fix validation error on UI side + rollback BE changes --- .../modules/redis/redis-connection.factory.ts | 179 +++++++++++------- 1 file changed, 115 insertions(+), 64 deletions(-) diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index a8a301ea7b..f95c9ce538 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -164,9 +164,12 @@ export class RedisConnectionFactory { options: IRedisConnectionOptions, ): Promise { let tnl; + let connection: Redis; try { const config = await this.getRedisOptions(clientMetadata, database, options); + // cover cases when we are connecting to sentinel using standalone client to discover master groups + const dbIndex = config.db > 0 && !database.sentinelMaster ? config.db : 0; if (database.ssh) { tnl = await this.sshTunnelProvider.createTunnel(database); @@ -189,10 +192,9 @@ export class RedisConnectionFactory { config.port = tnl.serverAddress.port; } - const connection = new Redis({ + connection = new Redis({ ...config, - // cover cases when we are connecting to sentinel as to standalone to discover master groups - db: config.db > 0 && !database.sentinelMaster ? config.db : 0, + db: 0, }); connection.on('error', (e): void => { this.logger.error('Failed connection to the redis database.', e); @@ -205,7 +207,16 @@ export class RedisConnectionFactory { connection.on('ready', (): void => { lastError = null; this.logger.log('Successfully connected to the redis database'); - resolve(connection); + + // manual switch to particular logical db + // since ioredis doesn't handle "select" command error during connection + if (dbIndex > 0) { + connection.select(dbIndex) + .then(() => resolve(connection)) + .catch(reject); + } else { + resolve(connection); + } }); connection.on('reconnecting', (): void => { lastError = null; @@ -216,6 +227,7 @@ export class RedisConnectionFactory { } }) as Redis; } catch (e) { + connection?.disconnect?.(); tnl?.close?.(); throw e; } @@ -232,43 +244,63 @@ export class RedisConnectionFactory { database: Database, options: IRedisConnectionOptions, ): Promise { - const config = await this.getRedisClusterOptions(clientMetadata, database, options); - - if (database.ssh) { - throw new Error('SSH is unsupported for cluster databases.'); - } + let connection: Cluster; - return new Promise((resolve, reject) => { - try { - let lastError: Error; + try { + const config = await this.getRedisClusterOptions(clientMetadata, database, options); - const cluster = new Cluster([{ - host: database.host, - port: database.port, - }].concat(database.nodes), { - ...config, - }); - cluster.on('error', (e): void => { - this.logger.error('Failed connection to the redis oss cluster', e); - lastError = !isEmpty(e.lastNodeError) ? e.lastNodeError : e; - }); - cluster.on('end', (): void => { - this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); - reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); - }); - cluster.on('ready', (): void => { - lastError = null; - this.logger.log('Successfully connected to the redis oss cluster.'); - resolve(cluster); - }); - cluster.on('reconnecting', (): void => { - lastError = null; - this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); - }); - } catch (e) { - reject(e); + if (database.ssh) { + throw new Error('SSH is unsupported for cluster databases.'); } - }); + + return await (new Promise((resolve, reject) => { + try { + let lastError: Error; + + connection = new Cluster([{ + host: database.host, + port: database.port, + }].concat(database.nodes), { + ...config, + redisOptions: { + ...config.redisOptions, + db: 0, + }, + }); + connection.on('error', (e): void => { + this.logger.error('Failed connection to the redis oss cluster', e); + lastError = !isEmpty(e.lastNodeError) ? e.lastNodeError : e; + }); + connection.on('end', (): void => { + this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); + reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + }); + connection.on('ready', (): void => { + lastError = null; + this.logger.log('Successfully connected to the redis oss cluster.'); + + // manual switch to particular logical db + // since ioredis doesn't handle "select" command error during connection + if (config.redisOptions.db > 0) { + connection.select(config.redisOptions.db) + .then(() => resolve(connection)) + .catch(reject); + } else { + resolve(connection); + } + }); + connection.on('reconnecting', (): void => { + lastError = null; + this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); + }); + } catch (e) { + reject(e); + } + })); + } catch (e) { + connection?.disconnect?.(); + throw e; + } } /** @@ -282,34 +314,53 @@ export class RedisConnectionFactory { database: Database, options: IRedisConnectionOptions, ): Promise { - const config = await this.getRedisSentinelOptions(clientMetadata, database, options); + let connection: Redis; - return new Promise((resolve, reject) => { - try { - let lastError: Error; + try { + const config = await this.getRedisSentinelOptions(clientMetadata, database, options); - const client = new Redis(config); - client.on('error', (e): void => { - this.logger.error('Failed connection to the redis oss sentinel', e); - lastError = e; - }); - client.on('end', (): void => { - this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); - reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); - }); - client.on('ready', (): void => { - lastError = null; - this.logger.log('Successfully connected to the redis oss sentinel.'); - resolve(client); - }); - client.on('reconnecting', (): void => { - lastError = null; - this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); - }); - } catch (e) { - reject(e); - } - }); + return await (new Promise((resolve, reject) => { + try { + let lastError: Error; + + connection = new Redis({ + ...config, + db: 0, + }); + connection.on('error', (e): void => { + this.logger.error('Failed connection to the redis oss sentinel', e); + lastError = e; + }); + connection.on('end', (): void => { + this.logger.error(ERROR_MESSAGES.UNABLE_TO_ESTABLISH_CONNECTION, lastError); + reject(lastError || new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + }); + connection.on('ready', (): void => { + lastError = null; + this.logger.log('Successfully connected to the redis oss sentinel.'); + + // manual switch to particular logical db + // since ioredis doesn't handle "select" command error during connection + if (config.db > 0) { + connection.select(config.db) + .then(() => resolve(connection)) + .catch(reject); + } else { + resolve(connection); + } + }); + connection.on('reconnecting', (): void => { + lastError = null; + this.logger.log(ERROR_MESSAGES.RECONNECTING_TO_DATABASE); + }); + } catch (e) { + reject(e); + } + })); + } catch (e) { + connection?.disconnect?.(); + throw e; + } } /** From cc3efc73f87617be99a5a64d4e3f7e79dd938a50 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 23 Nov 2023 19:01:41 +0200 Subject: [PATCH 82/96] fix RI-5193, RI-5191. overwrite dbIndex after successful "select" --- .../modules/redis/redis-connection.factory.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index f95c9ce538..c57a2e1078 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -3,7 +3,7 @@ import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common import { Database } from 'src/modules/database/models/database'; import apiConfig from 'src/utils/config'; import { ConnectionOptions } from 'tls'; -import { isEmpty, isNumber } from 'lodash'; +import { isEmpty, isNumber, set } from 'lodash'; import { cloneClassInstance, generateRedisConnectionName } from 'src/utils'; import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { ClientMetadata } from 'src/common/models'; @@ -212,7 +212,10 @@ export class RedisConnectionFactory { // since ioredis doesn't handle "select" command error during connection if (dbIndex > 0) { connection.select(dbIndex) - .then(() => resolve(connection)) + .then(() => { + set(connection, ['options', 'db'], dbIndex); + resolve(connection); + }) .catch(reject); } else { resolve(connection); @@ -283,7 +286,10 @@ export class RedisConnectionFactory { // since ioredis doesn't handle "select" command error during connection if (config.redisOptions.db > 0) { connection.select(config.redisOptions.db) - .then(() => resolve(connection)) + .then(() => { + set(connection, ['options', 'db'], config.redisOptions.db); + resolve(connection); + }) .catch(reject); } else { resolve(connection); @@ -343,7 +349,10 @@ export class RedisConnectionFactory { // since ioredis doesn't handle "select" command error during connection if (config.db > 0) { connection.select(config.db) - .then(() => resolve(connection)) + .then(() => { + set(connection, ['options', 'db'], config.db); + resolve(connection); + }) .catch(reject); } else { resolve(connection); From 64bce12a42e20561e56d8d2e7ed29a5bf52aa7c0 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Fri, 24 Nov 2023 10:55:28 +0100 Subject: [PATCH 83/96] 2.38.0 --- redisinsight/api/config/default.ts | 2 +- redisinsight/api/config/swagger.ts | 2 +- redisinsight/api/package.json | 2 +- redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts | 2 +- redisinsight/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 7346f1172f..10ba5e09c9 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -60,7 +60,7 @@ export default { tlsKey: process.env.SERVER_TLS_KEY, staticContent: !!process.env.SERVER_STATIC_CONTENT || false, buildType: process.env.BUILD_TYPE || 'ELECTRON', - appVersion: process.env.APP_VERSION || '2.36.0', + appVersion: process.env.APP_VERSION || '2.38.0', requestTimeout: parseInt(process.env.REQUEST_TIMEOUT, 10) || 25000, excludeRoutes: [], excludeAuthRoutes: [], diff --git a/redisinsight/api/config/swagger.ts b/redisinsight/api/config/swagger.ts index 22d24df01e..848273eb70 100644 --- a/redisinsight/api/config/swagger.ts +++ b/redisinsight/api/config/swagger.ts @@ -5,7 +5,7 @@ const SWAGGER_CONFIG: Omit = { info: { title: 'RedisInsight Backend API', description: 'RedisInsight Backend API', - version: '2.36.0', + version: '2.38.0', }, tags: [], }; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 633f3bb8e8..66e3007445 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -1,6 +1,6 @@ { "name": "redisinsight-api", - "version": "2.36.0", + "version": "2.38.0", "description": "RedisInsight API", "private": true, "author": { diff --git a/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts b/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts index 922a8c18c2..392158b196 100644 --- a/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts +++ b/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts @@ -8,7 +8,7 @@ const ICON_PATH = app.isPackaged export const AboutPanelOptions = { applicationName: 'RedisInsight', - applicationVersion: `${app.getVersion() || '2.36.0'}${ + applicationVersion: `${app.getVersion() || '2.38.0'}${ !config.isProduction ? `-dev-${process.getCreationTime()}` : '' }`, copyright: `Copyright © ${new Date().getFullYear()} Redis Ltd.`, diff --git a/redisinsight/package.json b/redisinsight/package.json index 2ec250a74f..b595f5ceed 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -2,7 +2,7 @@ "name": "redisinsight", "productName": "RedisInsight", "private": true, - "version": "2.36.0", + "version": "2.38.0", "description": "RedisInsight", "main": "./dist/main/main.js", "author": { From 619d103262304784de098aae3b87d9da5803943f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 27 Nov 2023 12:28:41 +0100 Subject: [PATCH 84/96] check failed tests on ci --- .circleci/config.yml | 2 +- tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts | 2 +- tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts | 2 +- tests/e2e/web.runner.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e273b4300f..46bdc58b9b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1163,7 +1163,7 @@ workflows: - e2e-tests: name: E2ETest build: docker - parallelism: 4 + parallelism: 1 requires: - Build docker image - e2e-app-image: diff --git a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts index b7c7a2826d..f3346154a1 100644 --- a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts @@ -36,7 +36,7 @@ fixture `Bulk Upload` await deleteAllKeysFromDB(dbParameters.host, dbParameters.port); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneRedisearch); }); -test('Verify bulk upload of different text docs formats', async t => { +test.only('Verify bulk upload of different text docs formats', async t => { // Verify bulk upload for docker app version const allKeysResults = ['9Commands Processed', '9Success', '0Errors']; const bigKeysResults = ['10 000Commands Processed', '10 000Success', '0Errors']; diff --git a/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts b/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts index 7899f7cba7..9368814de7 100644 --- a/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts @@ -38,7 +38,7 @@ fixture `Consumer group` await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneConfig.databaseName); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test('Verify that user can create a new Consumer Group in the current Stream', async t => { +test.only('Verify that user can create a new Consumer Group in the current Stream', async t => { const toolTip = [ 'Enter Valid ID, 0 or $', '\nSpecify the ID of the last delivered entry in the stream from the new group\'s perspective.', diff --git a/tests/e2e/web.runner.ts b/tests/e2e/web.runner.ts index c28353bfad..1f4e8a7d7a 100644 --- a/tests/e2e/web.runner.ts +++ b/tests/e2e/web.runner.ts @@ -38,7 +38,7 @@ import testcafe from 'testcafe'; selectorTimeout: 5000, assertionTimeout: 5000, speed: 1, - quarantineMode: { successThreshold: 1, attemptLimit: 3 }, + // quarantineMode: { successThreshold: 1, attemptLimit: 3 }, pageRequestTimeout: 8000, disableMultipleWindows: true }); From f25db673652dfb9dfe5795cbba9242132c27837f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 27 Nov 2023 17:00:16 +0100 Subject: [PATCH 85/96] fix --- tests/e2e/tests/web/smoke/cli/cli.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/tests/web/smoke/cli/cli.e2e.ts b/tests/e2e/tests/web/smoke/cli/cli.e2e.ts index 2ff1380b7a..6edb91366e 100644 --- a/tests/e2e/tests/web/smoke/cli/cli.e2e.ts +++ b/tests/e2e/tests/web/smoke/cli/cli.e2e.ts @@ -5,7 +5,7 @@ import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { Common } from '../../../../helpers/common'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; -import { goBackHistory, openRedisHomePage } from '../../../../helpers/utils'; +import { goBackHistory } from '../../../../helpers/utils'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -75,7 +75,7 @@ test.skip('Verify that user can use unblocking command', async t => { // Verify that user input is blocked await t.expect(browserPage.Cli.cliCommandInput.exists).notOk('Cli input is still shown'); // Create new window to unblock the client - await openRedisHomePage(); + await t.click(myRedisDatabasePage.NavigationPanel.myRedisDBButton); await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); // Open CLI await t.click(browserPage.Cli.cliExpandButton); From f721f8d36b24133e9ad062945783e67386d31955 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 27 Nov 2023 17:24:02 +0100 Subject: [PATCH 86/96] eslint fixes --- .circleci/config.yml | 2 +- tests/e2e/common-actions/workbench-actions.ts | 2 +- tests/e2e/desktop.runner.ts | 8 +- tests/e2e/desktop.runner.win.ts | 6 +- tests/e2e/helpers/api/api-database.ts | 30 +-- tests/e2e/helpers/api/api-info.ts | 2 +- tests/e2e/helpers/common.ts | 3 +- tests/e2e/helpers/database-scripts.ts | 12 +- tests/e2e/helpers/insights.ts | 2 +- tests/e2e/helpers/notifications.ts | 2 +- .../pageObjects/components/browser/index.ts | 2 +- .../myRedisDatabase/add-redis-database.ts | 3 +- tests/e2e/pageObjects/index.ts | 2 +- tests/e2e/pageObjects/workbench-page.ts | 2 +- .../critical-path/browser/bulk-upload.e2e.ts | 2 +- .../browser/consumer-group.e2e.ts | 2 +- .../database-overview/database-index.e2e.ts | 2 +- .../database-overview.e2e.ts | 5 +- .../database/import-databases.e2e.ts | 2 +- .../memory-efficiency/recommendations.e2e.ts | 4 +- .../memory-efficiency/top-keys-table.e2e.ts | 4 +- .../web/critical-path/workbench/cypher.e2e.ts | 2 +- .../workbench/default-scripts-area.e2e.ts | 2 +- .../web/regression/browser/add-keys.e2e.ts | 1 - .../regression/browser/resize-columns.e2e.ts | 1 - .../web/regression/browser/scan-keys.e2e.ts | 2 +- .../regression/workbench/autocomplete.e2e.ts | 2 +- .../web/regression/workbench/context.e2e.ts | 2 +- .../web/regression/workbench/cypher.e2e.ts | 2 +- .../workbench/default-scripts-area.e2e.ts | 236 +++++++++--------- .../workbench/empty-command-history.e2e.ts | 2 +- .../workbench/import-tutorials.e2e.ts | 2 +- tests/e2e/web.runner.ts | 10 +- 33 files changed, 180 insertions(+), 183 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 46bdc58b9b..e273b4300f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1163,7 +1163,7 @@ workflows: - e2e-tests: name: E2ETest build: docker - parallelism: 1 + parallelism: 4 requires: - Build docker image - e2e-app-image: diff --git a/tests/e2e/common-actions/workbench-actions.ts b/tests/e2e/common-actions/workbench-actions.ts index 4d495c1102..14242166b8 100644 --- a/tests/e2e/common-actions/workbench-actions.ts +++ b/tests/e2e/common-actions/workbench-actions.ts @@ -1,4 +1,4 @@ -import {t, Selector} from 'testcafe'; +import { t, Selector } from 'testcafe'; import { WorkbenchPage } from '../pageObjects'; const workbenchPage = new WorkbenchPage(); diff --git a/tests/e2e/desktop.runner.ts b/tests/e2e/desktop.runner.ts index c2081947e6..15effadce6 100644 --- a/tests/e2e/desktop.runner.ts +++ b/tests/e2e/desktop.runner.ts @@ -6,16 +6,16 @@ import testcafe from 'testcafe'; return t .createRunner() .compilerOptions({ - "typescript": { + 'typescript': { configPath: 'tsconfig.testcafe.json', experimentalDecorators: true - }}) + } }) .src((process.env.TEST_FILES || 'tests/electron/**/*.e2e.ts').split('\n')) .browsers(['electron']) .screenshots({ path: './report/screenshots/', takeOnFails: true, - pathPattern: '${USERAGENT}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png', + pathPattern: '${USERAGENT}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png' }) .reporter([ 'spec', @@ -46,7 +46,7 @@ import testcafe from 'testcafe'; process.exit(failedCount); }) .catch((e) => { - console.error(e) + console.error(e); process.exit(1); }); })(); diff --git a/tests/e2e/desktop.runner.win.ts b/tests/e2e/desktop.runner.win.ts index 17de2317e2..edb738b2a0 100644 --- a/tests/e2e/desktop.runner.win.ts +++ b/tests/e2e/desktop.runner.win.ts @@ -6,16 +6,16 @@ import testcafe from 'testcafe'; return t .createRunner() .compilerOptions({ - "typescript": { + 'typescript': { configPath: 'tsconfig.testcafe.json', experimentalDecorators: true - }}) + } }) .src((process.env.TEST_FILES || 'tests/electron/**/*.e2e.ts').split('\n')) .browsers(['electron']) .screenshots({ path: 'report/screenshots/', takeOnFails: true, - pathPattern: '${USERAGENT}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png', + pathPattern: '${USERAGENT}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png' }) .reporter([ 'spec', diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index e91051b7ea..c77262f827 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -27,28 +27,28 @@ export class DatabaseAPIRequests { ): Promise { const uniqueId = chance.string({ length: 10 }); const requestBody: { - name?: string; - host: string; - port: number; - username?: string; - password?: string; - tls?: boolean; - verifyServerCert?: boolean; + name?: string, + host: string, + port: number, + username?: string, + password?: string, + tls?: boolean, + verifyServerCert?: boolean, caCert?: { - name: string; - certificate?: string; - }; + name: string, + certificate?: string + }, clientCert?: { - name: string; - certificate?: string; - key?: string; - }; + name: string, + certificate?: string, + key?: string + } } = { name: databaseParameters.databaseName, host: databaseParameters.host, port: Number(databaseParameters.port), username: databaseParameters.databaseUsername, - password: databaseParameters.databasePassword, + password: databaseParameters.databasePassword }; if (databaseParameters.caCert) { diff --git a/tests/e2e/helpers/api/api-info.ts b/tests/e2e/helpers/api/api-info.ts index 7d41fdbd7f..978b0e9629 100644 --- a/tests/e2e/helpers/api/api-info.ts +++ b/tests/e2e/helpers/api/api-info.ts @@ -1,6 +1,6 @@ import { t } from 'testcafe'; -import { sendPostRequest } from './api-common'; import { ResourcePath } from '../constants'; +import { sendPostRequest } from './api-common'; /** * Synchronize features diff --git a/tests/e2e/helpers/common.ts b/tests/e2e/helpers/common.ts index 4505f631dc..9fcbd4a171 100644 --- a/tests/e2e/helpers/common.ts +++ b/tests/e2e/helpers/common.ts @@ -1,6 +1,6 @@ import * as path from 'path'; -import * as archiver from 'archiver'; import * as fs from 'fs'; +import * as archiver from 'archiver'; import { ClientFunction, RequestMock, t } from 'testcafe'; import { Chance } from 'chance'; import { apiUrl, commonUrl } from './conf'; @@ -13,7 +13,6 @@ declare global { } } - const settingsApiUrl = `${commonUrl}/api/settings`; process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] const mockedSettingsResponse = { diff --git a/tests/e2e/helpers/database-scripts.ts b/tests/e2e/helpers/database-scripts.ts index 9bc7ff95a9..faba4a431d 100644 --- a/tests/e2e/helpers/database-scripts.ts +++ b/tests/e2e/helpers/database-scripts.ts @@ -1,5 +1,5 @@ -import { workingDirectory } from '../helpers/conf'; import * as sqlite3 from 'sqlite3'; +import { workingDirectory } from '../helpers/conf'; const dbPath = `${workingDirectory}/redisinsight.db`; @@ -17,7 +17,8 @@ export async function updateColumnValueInDBTable(tableName: string, columnName: db.run(query, (err: { message: string }) => { if (err) { reject(new Error(`Error during changing ${columnName} column value: ${err.message}`)); - } else { + } + else { db.close(); resolve(); } @@ -38,7 +39,8 @@ export async function getColumnValueFromTableInDB(tableName: string, columnName: db.get(query, (err: { message: string }, row: any) => { if (err) { reject(new Error(`Error during getting ${columnName} column value: ${err.message}`)); - } else { + } + else { const columnValue = row[columnName]; db.close(); resolve(columnValue); @@ -57,11 +59,11 @@ export async function deleteRowsFromTableInDB(tableName: string): Promise return new Promise((resolve, reject) => { - db.run(query, (err: { message: string }) => { if (err) { reject(new Error(`Error during ${tableName} table rows deletion: ${err.message}`)); - } else { + } + else { db.close(); resolve(); } diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 6a3709d83b..240dedf2bf 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs-extra'; import * as path from 'path'; +import * as fs from 'fs-extra'; import { BasePage } from '../pageObjects'; import { deleteRowsFromTableInDB, updateColumnValueInDBTable } from './database-scripts'; import { syncFeaturesApi } from './api/api-info'; diff --git a/tests/e2e/helpers/notifications.ts b/tests/e2e/helpers/notifications.ts index ad17268074..b619b350ce 100644 --- a/tests/e2e/helpers/notifications.ts +++ b/tests/e2e/helpers/notifications.ts @@ -1,4 +1,4 @@ -import { workingDirectory} from '../helpers/conf'; +import { workingDirectory } from '../helpers/conf'; import { NotificationParameters } from '../pageObjects/components/navigation/notification-panel'; const dbPath = `${workingDirectory}/redisinsight.db`; diff --git a/tests/e2e/pageObjects/components/browser/index.ts b/tests/e2e/pageObjects/components/browser/index.ts index 79bba1bb76..d61d98397b 100644 --- a/tests/e2e/pageObjects/components/browser/index.ts +++ b/tests/e2e/pageObjects/components/browser/index.ts @@ -3,5 +3,5 @@ import { TreeView } from './tree-view'; export { BulkActions, - TreeView, + TreeView }; diff --git a/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts b/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts index 1c540c794e..104ba0dc9b 100644 --- a/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts +++ b/tests/e2e/pageObjects/components/myRedisDatabase/add-redis-database.ts @@ -266,9 +266,8 @@ export class AddRedisDatabase { /** * set copressor value in dropdown * @param compressor - compressor value - * @param value - checkbox value */ - async setCompressorValue(compressor: string){ + async setCompressorValue(compressor: string): Promise { if(!await this.selectCompressor.exists) { await t.click(this.dataCompressorLabel); diff --git a/tests/e2e/pageObjects/index.ts b/tests/e2e/pageObjects/index.ts index 855275febb..5d62c57a5c 100644 --- a/tests/e2e/pageObjects/index.ts +++ b/tests/e2e/pageObjects/index.ts @@ -27,5 +27,5 @@ export { InstancePage, TriggersAndFunctionsLibrariesPage, TriggersAndFunctionsFunctionsPage, - WelcomePage, + WelcomePage }; diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index c94303a85d..d04303adc6 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -290,6 +290,6 @@ export class WorkbenchPage extends InstancePage { do { imageHeight = await selector.getStyleProperty('height'); } - while ((imageHeight == '0px') && Date.now() - startTime < searchTimeout); + while ((imageHeight === '0px') && Date.now() - startTime < searchTimeout); } } diff --git a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts index f3346154a1..b7c7a2826d 100644 --- a/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/bulk-upload.e2e.ts @@ -36,7 +36,7 @@ fixture `Bulk Upload` await deleteAllKeysFromDB(dbParameters.host, dbParameters.port); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneRedisearch); }); -test.only('Verify bulk upload of different text docs formats', async t => { +test('Verify bulk upload of different text docs formats', async t => { // Verify bulk upload for docker app version const allKeysResults = ['9Commands Processed', '9Success', '0Errors']; const bigKeysResults = ['10 000Commands Processed', '10 000Success', '0Errors']; diff --git a/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts b/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts index 9368814de7..7899f7cba7 100644 --- a/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/consumer-group.e2e.ts @@ -38,7 +38,7 @@ fixture `Consumer group` await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneConfig.databaseName); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test.only('Verify that user can create a new Consumer Group in the current Stream', async t => { +test('Verify that user can create a new Consumer Group in the current Stream', async t => { const toolTip = [ 'Enter Valid ID, 0 or $', '\nSpecify the ID of the last delivered entry in the stream from the new group\'s perspective.', diff --git a/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts b/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts index 10cd1d4f2d..7f2e706480 100644 --- a/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts @@ -48,7 +48,7 @@ fixture `Allow to change database index` }); test('Switching between indexed databases', async t => { const command = `HSET ${logicalDbKey} "name" "Gillford School" "description" "Gillford School is a centre" "class" "private" "type" "democratic; waldorf" "address_city" "Goudhurst" "address_street" "Goudhurst" "students" 721 "location" "51.112685, 0.451076"`; - const rememberedConnectedClients = await browserPage.overviewConnectedClients.textContent; + // const rememberedConnectedClients = await browserPage.overviewConnectedClients.textContent; // Change index to logical db // Verify that database index switcher displayed for Standalone db diff --git a/tests/e2e/tests/web/critical-path/database-overview/database-overview.e2e.ts b/tests/e2e/tests/web/critical-path/database-overview/database-overview.e2e.ts index b6bd6cb85f..1a8941ae09 100644 --- a/tests/e2e/tests/web/critical-path/database-overview/database-overview.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database-overview/database-overview.e2e.ts @@ -16,7 +16,6 @@ import { } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; - const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); const chance = new Chance(); @@ -47,8 +46,8 @@ test await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneRedisearch); })('Verify that user can see the list of Modules updated each time when he connects to the database', async t => { - let firstDatabaseModules: string[] = []; - let secondDatabaseModules: string[] = []; + const firstDatabaseModules: string[] = []; + const secondDatabaseModules: string[] = []; //Remember modules await t.click(browserPage.OverviewPanel.overviewMoreInfo); const moduleIcons = Selector('div').find('[data-testid^=Redi]'); diff --git a/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts index 4ac60774e9..971fa8f00b 100644 --- a/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database/import-databases.e2e.ts @@ -233,7 +233,7 @@ test('Certificates import with/without path', async t => { }); test('Import SSH parameters', async t => { const sshAgentsResult = 'SSH Agents are not supported'; - const sshPrivateKey = '-----BEGIN OPENSSH PRIVATE KEY-----'; + // const sshPrivateKey = '-----BEGIN OPENSSH PRIVATE KEY-----'; await databasesActions.importDatabase(racompSSHData); // Fully imported table with SSH diff --git a/tests/e2e/tests/web/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/web/critical-path/memory-efficiency/recommendations.e2e.ts index 776c4a7daf..87ff75a84d 100644 --- a/tests/e2e/tests/web/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/web/critical-path/memory-efficiency/recommendations.e2e.ts @@ -39,7 +39,7 @@ fixture `Memory Efficiency Recommendations` // Go to Analysis Tools page await t.click(myRedisDatabasePage.NavigationPanel.analysisPageButton); }) - .afterEach(async t => { + .afterEach(async() => { // Clear and delete database await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneConfig.databaseName); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); @@ -174,7 +174,7 @@ test.skip // Go to Recommendations tab await t.click(memoryEfficiencyPage.recommendationsTab); }) - .after(async t => { + .after(async() => { // Clear and delete database await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneConfig.databaseName); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); diff --git a/tests/e2e/tests/web/critical-path/memory-efficiency/top-keys-table.e2e.ts b/tests/e2e/tests/web/critical-path/memory-efficiency/top-keys-table.e2e.ts index 9e417e3ebe..d902c93ba3 100644 --- a/tests/e2e/tests/web/critical-path/memory-efficiency/top-keys-table.e2e.ts +++ b/tests/e2e/tests/web/critical-path/memory-efficiency/top-keys-table.e2e.ts @@ -3,7 +3,7 @@ import { Selector } from 'testcafe'; import { MyRedisDatabasePage, MemoryEfficiencyPage, BrowserPage } from '../../../../pageObjects'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { commonUrl, ossStandaloneConfig, ossStandaloneRedisearch } from '../../../../helpers/conf'; +import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { deleteAllKeysFromDB, populateDBWithHashes, populateHashWithFields } from '../../../../helpers/keys'; import { Common } from '../../../../helpers/common'; @@ -80,7 +80,7 @@ test // Go to Analysis Tools page await t.click(myRedisDatabasePage.NavigationPanel.analysisPageButton); }) - .after(async t => { + .after(async() => { await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneRedisearch.databaseName); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneRedisearch); })('Big highlighted key tooltip', async t => { diff --git a/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts index 288c300b0c..f7db81f308 100644 --- a/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts @@ -10,7 +10,7 @@ const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); fixture `Cypher syntax at Workbench` - .meta({type: 'critical_path', rte: rte.standalone}) + .meta({ type: 'critical_path', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); diff --git a/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts index 271e86e35c..64d8ef1723 100644 --- a/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts @@ -24,7 +24,7 @@ const expectedProperties = [ ]; fixture `Default scripts area at Workbench` - .meta({type: 'critical_path', rte: rte.standalone}) + .meta({ type: 'critical_path', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); diff --git a/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts b/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts index 4da14a05ab..318bbcd677 100644 --- a/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/add-keys.e2e.ts @@ -5,7 +5,6 @@ import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../.. import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; import { BrowserActions } from '../../../../common-actions/browser-actions'; -import { verifyKeysDisplayingInTheList } from '../../../../helpers/keys'; const browserPage = new BrowserPage(); const browserActions = new BrowserActions(); diff --git a/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts b/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts index 9f1e3e946f..5f86ecff1c 100644 --- a/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts @@ -7,7 +7,6 @@ import { rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; -import { t } from 'testcafe'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); diff --git a/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts b/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts index 647e1195f4..33432f584f 100644 --- a/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts @@ -16,7 +16,7 @@ const explicitErrorHandler = (): void => { }; fixture `Browser - Specify Keys to Scan` - .meta({type: 'regression', rte: rte.none}) + .meta({ type: 'regression', rte: rte.none }) .page(commonUrl) .clientScripts({ content: `(${explicitErrorHandler.toString()})()` }) .beforeEach(async() => { diff --git a/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts b/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts index 2a1f478576..12a7de4b41 100644 --- a/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts @@ -10,7 +10,7 @@ const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); fixture `Autocomplete for entered commands` - .meta({type: 'regression', rte: rte.standalone}) + .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); diff --git a/tests/e2e/tests/web/regression/workbench/context.e2e.ts b/tests/e2e/tests/web/regression/workbench/context.e2e.ts index 224070a4b9..3aded27a14 100644 --- a/tests/e2e/tests/web/regression/workbench/context.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/context.e2e.ts @@ -50,7 +50,7 @@ test('Verify that user can see saved CLI size when navigates away to any other p test('Verify that user can see all the information removed when reloads the page', async t => { const command = 'FT._LIST'; // Create context modificaions and navigate to Browser - await t.typeText(workbenchPage.queryInput, command, { replace: true, speed: speed}); + await t.typeText(workbenchPage.queryInput, command, { replace: true, speed: speed }); await t.click(workbenchPage.Cli.cliExpandButton); await t.click(myRedisDatabasePage.NavigationPanel.browserButton); // Open Workbench page and verify context diff --git a/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts b/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts index e65ff793b2..be706fb127 100644 --- a/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts @@ -12,7 +12,7 @@ const databaseAPIRequests = new DatabaseAPIRequests(); const command = 'GRAPH.QUERY graph'; fixture `Cypher syntax at Workbench` - .meta({type: 'regression', rte: rte.standalone}) + .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); diff --git a/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts b/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts index c523cf3af4..e0ee1990d4 100644 --- a/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts @@ -10,7 +10,7 @@ const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); fixture `Default scripts area at Workbench` - .meta({type: 'regression', rte: rte.standalone}) + .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); @@ -20,132 +20,132 @@ fixture `Default scripts area at Workbench` .afterEach(async() => { // Delete database await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); - }) -test('Verify that user can expand/collapse the enablement area', async t => { - // Hover over Enablement area - await t.hover(workbenchPage.preselectArea); - // Collapse the area with default scripts - await t.click(workbenchPage.collapsePreselectAreaButton); - // Validate that Enablement area is not displayed - await t.expect(workbenchPage.preselectArea.visible).notOk('Enablement area is not collapsed'); - // Expand Enablement area - await t.click(workbenchPage.expandPreselectAreaButton); - // Validate that Enablement area is displayed - await t.expect(workbenchPage.preselectArea.visible).ok('Enablement area is not expanded'); }); +test('Verify that user can expand/collapse the enablement area', async t => { + // Hover over Enablement area + await t.hover(workbenchPage.preselectArea); + // Collapse the area with default scripts + await t.click(workbenchPage.collapsePreselectAreaButton); + // Validate that Enablement area is not displayed + await t.expect(workbenchPage.preselectArea.visible).notOk('Enablement area is not collapsed'); + // Expand Enablement area + await t.click(workbenchPage.expandPreselectAreaButton); + // Validate that Enablement area is displayed + await t.expect(workbenchPage.preselectArea.visible).ok('Enablement area is not expanded'); +}); test('Verify that user can see the [Manual] option in the Enablement area', async t => { - const optionsForCheck = [ - 'Manual', - 'List the Indices', - 'Index info', - 'Search', - 'Aggregate' - ]; + const optionsForCheck = [ + 'Manual', + 'List the Indices', + 'Index info', + 'Search', + 'Aggregate' + ]; - // Remember the options displayed in the area - const countOfOptions = await workbenchPage.preselectButtons.count; - const displayedOptions: string[] = []; - for(let i = 0; i < countOfOptions; i++) { - displayedOptions.push(await workbenchPage.preselectButtons.nth(i).textContent); - } - // Verify the options in the area - for(let i = 0; i < countOfOptions; i++) { - await t.expect(displayedOptions[i]).eql(optionsForCheck[i], `Option ${optionsForCheck} is not in the Enablement area`); - } - }); + // Remember the options displayed in the area + const countOfOptions = await workbenchPage.preselectButtons.count; + const displayedOptions: string[] = []; + for(let i = 0; i < countOfOptions; i++) { + displayedOptions.push(await workbenchPage.preselectButtons.nth(i).textContent); + } + // Verify the options in the area + for(let i = 0; i < countOfOptions; i++) { + await t.expect(displayedOptions[i]).eql(optionsForCheck[i], `Option ${optionsForCheck} is not in the Enablement area`); + } +}); test('Verify that user can see saved article in Enablement area when he leaves Workbench page and goes back again', async t => { - await t.click(workbenchPage.documentButtonInQuickGuides); - await t.expect(workbenchPage.internalLinkWorkingWithHashes.visible).ok('The working with hachs link is not visible', { timeout: 5000 }); - // Open Working with Hashes section - await t.click(workbenchPage.internalLinkWorkingWithHashes); - // Check the button from Hash page is visible - await t.expect(workbenchPage.preselectHashCreate.visible).ok('The end of the page is not visible'); - // Go to Browser page - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); - // Go back to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); - // Verify that the same article is opened in Enablement area - await t.expect(workbenchPage.preselectHashCreate.visible).ok('The end of the page is not visible'); - // Go to list of DBs page - await t.click(myRedisDatabasePage.NavigationPanel.myRedisDBButton); - // Go back to active DB again - await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); - // Check that user is on Workbench page and "Working with Hashes" page is displayed - await t.expect(workbenchPage.preselectHashCreate.visible).ok('The end of the page is not visible'); - }); + await t.click(workbenchPage.documentButtonInQuickGuides); + await t.expect(workbenchPage.internalLinkWorkingWithHashes.visible).ok('The working with hachs link is not visible', { timeout: 5000 }); + // Open Working with Hashes section + await t.click(workbenchPage.internalLinkWorkingWithHashes); + // Check the button from Hash page is visible + await t.expect(workbenchPage.preselectHashCreate.visible).ok('The end of the page is not visible'); + // Go to Browser page + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + // Go back to Workbench page + await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + // Verify that the same article is opened in Enablement area + await t.expect(workbenchPage.preselectHashCreate.visible).ok('The end of the page is not visible'); + // Go to list of DBs page + await t.click(myRedisDatabasePage.NavigationPanel.myRedisDBButton); + // Go back to active DB again + await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); + // Check that user is on Workbench page and "Working with Hashes" page is displayed + await t.expect(workbenchPage.preselectHashCreate.visible).ok('The end of the page is not visible'); +}); //skipped due the issue RI-2384 test.skip('Verify that user can see saved scroll position in Enablement area when he leaves Workbench page and goes back again', async t => { - // Open Working with Hashes section - await t.click(workbenchPage.documentButtonInQuickGuides); - await t.click(workbenchPage.internalLinkWorkingWithHashes); - // Evaluate the last button in Enablement Area - const buttonsQuantity = await workbenchPage.preselectButtons.count; - const lastButton = workbenchPage.preselectButtons.nth(buttonsQuantity - 1); - // Scroll to the very bottom of the page - await t.scrollIntoView(lastButton); - // Check the scroll position - const scrollPosition = await workbenchPage.scrolledEnablementArea.scrollTop; - // Go to Browser page - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); - // Go back to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); - // Check that scroll position is saved - await t.expect(await workbenchPage.scrolledEnablementArea.scrollTop).eql(scrollPosition, 'The scroll position status is incorrect'); - // Go to list of DBs page - await t.click(myRedisDatabasePage.NavigationPanel.myRedisDBButton); - // Go back to active DB again - await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); - // Check that scroll position is saved - await t.expect(await workbenchPage.scrolledEnablementArea.scrollTop).eql(scrollPosition, 'Scroll position is not correct'); - }); + // Open Working with Hashes section + await t.click(workbenchPage.documentButtonInQuickGuides); + await t.click(workbenchPage.internalLinkWorkingWithHashes); + // Evaluate the last button in Enablement Area + const buttonsQuantity = await workbenchPage.preselectButtons.count; + const lastButton = workbenchPage.preselectButtons.nth(buttonsQuantity - 1); + // Scroll to the very bottom of the page + await t.scrollIntoView(lastButton); + // Check the scroll position + const scrollPosition = await workbenchPage.scrolledEnablementArea.scrollTop; + // Go to Browser page + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + // Go back to Workbench page + await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + // Check that scroll position is saved + await t.expect(await workbenchPage.scrolledEnablementArea.scrollTop).eql(scrollPosition, 'The scroll position status is incorrect'); + // Go to list of DBs page + await t.click(myRedisDatabasePage.NavigationPanel.myRedisDBButton); + // Go back to active DB again + await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); + // Check that scroll position is saved + await t.expect(await workbenchPage.scrolledEnablementArea.scrollTop).eql(scrollPosition, 'Scroll position is not correct'); +}); test('Verify that user can see the siblings menu by clicking on page counter element between Back and Next buttons', async t => { - const popoverButtons = [ - 'Introduction', - 'Working with Hashes', - 'Working with JSON', - 'Learn More' - ] + const popoverButtons = [ + 'Introduction', + 'Working with Hashes', + 'Working with JSON', + 'Learn More' + ]; - // Open Working with Hashes section and click on the on page counter - await t.click(workbenchPage.documentButtonInQuickGuides); - await t.expect(workbenchPage.internalLinkWorkingWithHashes.visible).ok('The working with hachs link is not visible', { timeout: 5000 }); - await t.click(workbenchPage.internalLinkWorkingWithHashes); - // Verify that user can see the quick navigation section to navigate between siblings under the scrolling content - await t.expect(workbenchPage.enablementAreaPagination.visible).ok('The quick navigation section is not displayed'); + // Open Working with Hashes section and click on the on page counter + await t.click(workbenchPage.documentButtonInQuickGuides); + await t.expect(workbenchPage.internalLinkWorkingWithHashes.visible).ok('The working with hachs link is not visible', { timeout: 5000 }); + await t.click(workbenchPage.internalLinkWorkingWithHashes); + // Verify that user can see the quick navigation section to navigate between siblings under the scrolling content + await t.expect(workbenchPage.enablementAreaPagination.visible).ok('The quick navigation section is not displayed'); - await t.click(workbenchPage.enablementAreaPagination); - // Verify the siblings menu - await t.expect(workbenchPage.enablementAreaPaginationPopover.visible).ok('The siblings menu is not displayed'); - const countOfButtons = await workbenchPage.paginationPopoverButtons.count; - for (let i = 0; i < countOfButtons; i++) { - let popoverButton = workbenchPage.paginationPopoverButtons.nth(i); - await t.expect(popoverButton.textContent).eql(popoverButtons[i], `The siblings menu button ${popoverButtons[i]} is not displayed`); - } - }); + await t.click(workbenchPage.enablementAreaPagination); + // Verify the siblings menu + await t.expect(workbenchPage.enablementAreaPaginationPopover.visible).ok('The siblings menu is not displayed'); + const countOfButtons = await workbenchPage.paginationPopoverButtons.count; + for (let i = 0; i < countOfButtons; i++) { + const popoverButton = workbenchPage.paginationPopoverButtons.nth(i); + await t.expect(popoverButton.textContent).eql(popoverButtons[i], `The siblings menu button ${popoverButtons[i]} is not displayed`); + } +}); test('Verify that the same type of content is supported in the “Tutorials” as in the “Quick Guides”', async t => { - const tutorialsContent = [ - 'Working with JSON', - 'Vector Similarity Search', - 'Redis for time series', - 'Probabilistic data structures' - ]; - const command = 'HSET bikes:10000 '; + const tutorialsContent = [ + 'Working with JSON', + 'Vector Similarity Search', + 'Redis for time series', + 'Probabilistic data structures' + ]; + const command = 'HSET bikes:10000 '; - // Verify the redis stack links - await t.click(workbenchPage.redisStackTutorialsButton); - const linksCount = await workbenchPage.redisStackLinks.count; - for(let i = 0; i < linksCount; i++) { - await t.expect(workbenchPage.redisStackLinks.nth(i).textContent).eql(tutorialsContent[i], `The link ${tutorialsContent[i]} is in the Enablement area`); - } - // Verify the load script to Editor - await t.click(workbenchPage.vectorSimilitaritySearchButton); - // Verify that user can see the pagination for redis stack pages in Tutorials - await t.expect(workbenchPage.enablementAreaPagination.visible).ok('The user can not see the pagination for redis stack pages'); - await t.expect(workbenchPage.nextPageButton.visible).ok('The user can not see the next page for redis stack pages'); - await t.expect(workbenchPage.prevPageButton.visible).ok('The user can not see the prev page for redis stack pages'); + // Verify the redis stack links + await t.click(workbenchPage.redisStackTutorialsButton); + const linksCount = await workbenchPage.redisStackLinks.count; + for(let i = 0; i < linksCount; i++) { + await t.expect(workbenchPage.redisStackLinks.nth(i).textContent).eql(tutorialsContent[i], `The link ${tutorialsContent[i]} is in the Enablement area`); + } + // Verify the load script to Editor + await t.click(workbenchPage.vectorSimilitaritySearchButton); + // Verify that user can see the pagination for redis stack pages in Tutorials + await t.expect(workbenchPage.enablementAreaPagination.visible).ok('The user can not see the pagination for redis stack pages'); + await t.expect(workbenchPage.nextPageButton.visible).ok('The user can not see the next page for redis stack pages'); + await t.expect(workbenchPage.prevPageButton.visible).ok('The user can not see the prev page for redis stack pages'); - await t.expect(workbenchPage.queryInputScriptArea.textContent).eql('', 'The editor is not empty'); - await t.click(workbenchPage.hashWithVectorButton); - const editorContent = (await workbenchPage.queryInputScriptArea.textContent).replace(/\s/g, ' ') - await t.expect(editorContent).eql(command, 'The selected command is not in the Editor'); - }); + await t.expect(workbenchPage.queryInputScriptArea.textContent).eql('', 'The editor is not empty'); + await t.click(workbenchPage.hashWithVectorButton); + const editorContent = (await workbenchPage.queryInputScriptArea.textContent).replace(/\s/g, ' '); + await t.expect(editorContent).eql(command, 'The selected command is not in the Editor'); +}); diff --git a/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts b/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts index cecc4d1397..fce78be20c 100644 --- a/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts @@ -11,7 +11,7 @@ const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); fixture `Empty command history in Workbench` - .meta({type: 'regression'}) + .meta({ type: 'regression' }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); diff --git a/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts b/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts index af56e3c824..6f9e1c5593 100644 --- a/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/import-tutorials.e2e.ts @@ -158,7 +158,7 @@ test await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); await workbenchPage.deleteTutorialByName(tutorialName); await t.expect(workbenchPage.tutorialAccordionButton.withText(tutorialName).exists) - .notOk(`${tutorialName} tutorial is not deleted`); + .notOk(`${tutorialName} tutorial is not deleted`); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneRedisearch); })('Verify that user can bulk upload data from custom tutorial', async t => { const allKeysResults = ['9Commands Processed', '9Success', '0Errors']; diff --git a/tests/e2e/web.runner.ts b/tests/e2e/web.runner.ts index 1f4e8a7d7a..e77ccc1030 100644 --- a/tests/e2e/web.runner.ts +++ b/tests/e2e/web.runner.ts @@ -6,16 +6,16 @@ import testcafe from 'testcafe'; return t .createRunner() .compilerOptions({ - "typescript": { + 'typescript': { configPath: 'tsconfig.testcafe.json', experimentalDecorators: true - }}) + } }) .src((process.env.TEST_FILES || 'tests/web/**/*.e2e.ts').split('\n')) .browsers(['chromium:headless --cache --allow-insecure-localhost --ignore-certificate-errors']) .screenshots({ path: 'report/screenshots/', takeOnFails: true, - pathPattern: '${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png', + pathPattern: '${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png' }) .reporter([ 'spec', @@ -38,7 +38,7 @@ import testcafe from 'testcafe'; selectorTimeout: 5000, assertionTimeout: 5000, speed: 1, - // quarantineMode: { successThreshold: 1, attemptLimit: 3 }, + quarantineMode: { successThreshold: 1, attemptLimit: 3 }, pageRequestTimeout: 8000, disableMultipleWindows: true }); @@ -47,7 +47,7 @@ import testcafe from 'testcafe'; process.exit(failedCount); }) .catch((e) => { - console.error(e) + console.error(e); process.exit(1); }); })(); From 50de8639c646318c66481546b292dfb147a91aa2 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 28 Nov 2023 12:26:25 +0100 Subject: [PATCH 87/96] update for formatters from RI-5206 --- tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts b/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts index d39ca364e1..eb247723bd 100644 --- a/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts @@ -205,6 +205,8 @@ notEditableFormattersSet.forEach(formatter => { await t.hover(editBtn); // Verify tooltip content await t.expect(browserPage.tooltip.textContent).contains('Cannot edit the value in this format', 'Tooltip has wrong text'); + await t.click(editBtn); + await t.expect(browserPage.applyButton.exists).notOk('Edit field is displayed even if disabled'); } } }); From ceb0c260aa6a662d7cdcb85d5bd57cc7a870df5f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 28 Nov 2023 12:58:46 +0100 Subject: [PATCH 88/96] fix for formatters --- tests/e2e/pageObjects/browser-page.ts | 2 +- .../web/critical-path/browser/formatters.e2e.ts | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 98787eb7bf..8d698561df 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -39,7 +39,7 @@ export class BrowserPage extends InstancePage { refreshKeysButton = Selector('[data-testid=refresh-keys-btn]'); refreshKeyButton = Selector('[data-testid=refresh-key-btn]'); editKeyNameButton = Selector('[data-testid=edit-key-btn]'); - editKeyValueButton = Selector('[data-testid=edit-key-value-btn]'); + editKeyValueButton = Selector('[data-testid=edit-key-value-btn]', { timeout: 500 }); closeKeyButton = Selector('[data-testid=close-key-btn]'); plusAddKeyButton = Selector('[data-testid=btn-add-key]'); addKeyValueItemsButton = Selector('[data-testid=add-key-value-items-btn]'); diff --git a/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts b/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts index eb247723bd..ee887690a5 100644 --- a/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts @@ -57,11 +57,11 @@ formattersHighlightedSet.forEach(formatter => { // Verify that value is formatted and highlighted await t.expect(valueSelector.find(browserPage.cssJsonValue).exists).ok(`${key.textType} Value is not formatted to ${formatter.format}`); // Verify that Hash field is formatted and highlighted for JSON and PHP serialized - if (key.keyName === 'hash') { + if (key.textType === 'Hash') { await t.expect(browserPage.hashField.find(browserPage.cssJsonValue).exists).ok(`Hash field is not formatted to ${formatter.format}`); } // Verify that Stream field is formatted and highlighted for JSON and PHP serialized - if (key.keyName === 'stream') { + if (key.textType === 'Stream') { await t.expect(Selector(browserPage.cssJsonValue).count).eql(2, `Hash field is not formatted to ${formatter.format}`); } } @@ -193,10 +193,10 @@ notEditableFormattersSet.forEach(formatter => { // Verify for Protobuf, Java serialized, Pickle // Verify for Hash, List, ZSet, String keys for (const key of keysData) { - if (key.keyName === 'hash' || key.keyName === 'list' || key.keyName === 'zset' || key.keyName === 'string') { - const editBtn = (key.keyName === 'string') + if (key.textType === 'Hash' || key.textType === 'List' || key.textType === 'Sorted Set' || key.textType === 'String') { + const editBtn = (key.textType === 'String') ? browserPage.editKeyValueButton - : Selector(`[data-testid^=edit-][data-testid*=${key.keyName.split('-')[0]}]`); + : Selector(`[data-testid*=edit-][data-testid*=${key.keyName.split('-')[0]}]`, { timeout: 500 }); await browserPage.openKeyDetailsByKeyName(key.keyName); await browserPage.selectFormatter(formatter.format); // Verify that edit button disabled @@ -205,8 +205,6 @@ notEditableFormattersSet.forEach(formatter => { await t.hover(editBtn); // Verify tooltip content await t.expect(browserPage.tooltip.textContent).contains('Cannot edit the value in this format', 'Tooltip has wrong text'); - await t.click(editBtn); - await t.expect(browserPage.applyButton.exists).notOk('Edit field is displayed even if disabled'); } } }); From eb033f56dbaa11a68f4f14c6d22ec4e2792fcc7e Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Tue, 28 Nov 2023 14:50:06 +0100 Subject: [PATCH 89/96] #RI-5206 - fix button state for editing #RI-5207 - fix string details heights --- .../string-details/StringDetails.spec.tsx | 21 ++++++++++++++++++- .../string-details/StringDetails.tsx | 2 +- .../StringDetailsTable.tsx | 21 +++++++------------ .../string-details-table/styles.module.scss | 16 ++++++-------- 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx index dbe2cbaebf..2b50cd7536 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render, screen } from 'uiSrc/utils/test-utils' -import { stringDataSelector } from 'uiSrc/slices/browser/string' +import { stringDataSelector, stringSelector } from 'uiSrc/slices/browser/string' import { Props, StringDetails } from './StringDetails' const mockedProps = mock() @@ -15,6 +15,9 @@ jest.mock('uiSrc/slices/browser/string', () => ({ data: [49, 50, 51, 52], } }), + stringSelector: jest.fn().mockReturnValue({ + isCompressed: false + }) })) jest.mock('uiSrc/slices/browser/keys', () => ({ @@ -64,6 +67,22 @@ describe('StringDetails', () => { expect(editValueBtn).toHaveProperty('disabled', true) }) + it('should not be able to change value (compressed)', () => { + const stringSelectorMock = jest.fn().mockReturnValue({ + isCompressed: true + }) + stringSelector.mockImplementation(stringSelectorMock) + + render( + + ) + + const editValueBtn = screen.getByTestId(`${EDIT_VALUE_BTN_TEST_ID}`) + expect(editValueBtn).toHaveProperty('disabled', true) + }) + it('"edit-key-value-btn" should render', () => { const { queryByTestId } = render() expect(queryByTestId('edit-key-value-btn')).toBeInTheDocument() diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx index bea335540b..279614065c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/StringDetails.tsx @@ -56,7 +56,7 @@ const StringDetails = (props: Props) => { setEditItem(!editItem)} /> ) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx index cd92f6115b..f590c9e928 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx @@ -51,8 +51,7 @@ import { IFetchKeyArgs } from 'uiSrc/constants/prop-types/keys' import styles from './styles.module.scss' -const MAX_ROWS = 25 -const MIN_ROWS = 4 +const MIN_ROWS = 8 const APPROXIMATE_WIDTH_OF_SIGN = 8.6 const MAX_LENGTH = STRING_MAX_LENGTH + 1 @@ -72,7 +71,7 @@ const StringDetailsTable = (props: Props) => { const { name: key, type: keyType, length } = useSelector(selectedKeyDataSelector) ?? { name: '' } const { viewFormat: viewFormatProp } = useSelector(selectedKeySelector) - const [rows, setRows] = useState(5) + const [rows, setRows] = useState(MIN_ROWS) const [value, setValue] = useState('') const [areaValue, setAreaValue] = useState('') const [viewFormat, setViewFormat] = useState(viewFormatProp) @@ -83,6 +82,7 @@ const StringDetailsTable = (props: Props) => { const textAreaRef: Ref = useRef(null) const viewValueRef: Ref = useRef(null) + const containerRef: Ref = useRef(null) const dispatch = useDispatch() @@ -125,16 +125,9 @@ const StringDetailsTable = (props: Props) => { return } const calculatedRows = calculateTextareaLines(areaValue, textAreaRef.current.clientWidth, APPROXIMATE_WIDTH_OF_SIGN) - - if (calculatedRows > MAX_ROWS) { - setRows(MAX_ROWS) - return - } - if (calculatedRows < MIN_ROWS) { - setRows(MIN_ROWS) - return + if (calculatedRows > MIN_ROWS) { + setRows(calculatedRows) } - setRows(calculatedRows) }, [viewValueRef, isEditItem]) useMemo(() => { @@ -188,7 +181,7 @@ const StringDetailsTable = (props: Props) => { return ( <> -
+
{isLoading && ( { )} {!isEditItem && ( isEditable && setIsEdit(true)} style={{ whiteSpace: 'break-spaces' }} data-testid="string-value" @@ -253,6 +247,7 @@ const StringDetailsTable = (props: Props) => { disabled={loading} inputRef={textAreaRef} className={cx(styles.stringTextArea, { [styles.areaWarning]: isDisabled })} + style={{ maxHeight: containerRef.current ? containerRef.current?.clientHeight - 80 : '100%' }} data-testid="string-value" /> diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/styles.module.scss b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/styles.module.scss index 3ae9881baf..8efcbe220f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/styles.module.scss @@ -6,14 +6,10 @@ $outer-height: 220px; $outer-height-mobile: 340px; .container { - @include euiScrollBar; padding: 20px 16px 20px; - max-height: calc(100vh - #{$outer-height}); - overflow-y: auto; - overflow-x: hidden; - word-break: break-word; + overflow: hidden; color: var(--euiTextSubduedColor); flex: 1; position: relative; @@ -29,6 +25,11 @@ $outer-height-mobile: 340px; } .stringValue { + @include euiScrollBar; + overflow-y: auto; + overflow-x: hidden; + word-break: break-word; + font: inherit !important; color: inherit !important; padding: 0 !important; @@ -41,11 +42,6 @@ $outer-height-mobile: 340px; } .stringTextArea { - max-height: calc(100vh - #{$outer-height} - 55px); - @media only screen and (max-width: 767px) { - max-height: calc(100vh - #{$outer-height-mobile} - 55px); - } - &.areaWarning { border-color: var(--euiColorWarningLight) !important; background-image: none !important; From 51261211c20999047feca4354ab7d908925684ce Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 28 Nov 2023 15:00:04 +0100 Subject: [PATCH 90/96] fix for zset formatter --- .../tests/web/critical-path/browser/formatters.e2e.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts b/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts index ee887690a5..89ca3e7946 100644 --- a/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts @@ -193,7 +193,7 @@ notEditableFormattersSet.forEach(formatter => { // Verify for Protobuf, Java serialized, Pickle // Verify for Hash, List, ZSet, String keys for (const key of keysData) { - if (key.textType === 'Hash' || key.textType === 'List' || key.textType === 'Sorted Set' || key.textType === 'String') { + if (key.textType === 'Hash' || key.textType === 'List' || key.textType === 'String') { const editBtn = (key.textType === 'String') ? browserPage.editKeyValueButton : Selector(`[data-testid*=edit-][data-testid*=${key.keyName.split('-')[0]}]`, { timeout: 500 }); @@ -206,6 +206,13 @@ notEditableFormattersSet.forEach(formatter => { // Verify tooltip content await t.expect(browserPage.tooltip.textContent).contains('Cannot edit the value in this format', 'Tooltip has wrong text'); } + if (key.textType === 'Sorted Set') { + const editBtn = Selector(`[data-testid*=edit-][data-testid*=${key.keyName.split('-')[0]}]`, { timeout: 500 }); + await browserPage.openKeyDetailsByKeyName(key.keyName); + await browserPage.selectFormatter(formatter.format); + // Verify that edit button enabled for ZSet + await t.expect(editBtn.hasAttribute('disabled')).notOk(`Key ${key.textType} is disabled for ${formatter.format} formatter`); + } } }); }); From d401c511266649cfc2f753aee6790dc0b8319a46 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Wed, 29 Nov 2023 11:57:58 +0100 Subject: [PATCH 91/96] #RI-5213, #RI-5212 - fix tooltip appearing --- .../StringDetailsTable.tsx | 36 +++++++++---------- .../string-details-table/styles.module.scss | 11 +++--- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx index f590c9e928..2094686822 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx @@ -191,28 +191,24 @@ const StringDetailsTable = (props: Props) => { /> )} {!isEditItem && ( - isEditable && setIsEdit(true)} - style={{ whiteSpace: 'break-spaces' }} - data-testid="string-value" + - {areaValue !== '' - ? (isValid + isEditable && setIsEdit(true)} + style={{ whiteSpace: 'break-spaces' }} + data-testid="string-value" + > + {areaValue !== '' ? value - : ( - - <>{value} - - ) - ) - : (!isLoading && (Empty))} - + : (!isLoading && (Empty))} + + )} {isEditItem && ( Date: Wed, 29 Nov 2023 15:33:02 +0100 Subject: [PATCH 92/96] #RI-5213 - fix tooltip position, fix scroll --- .../string-details-table/StringDetailsTable.tsx | 2 +- .../string-details/string-details-table/styles.module.scss | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx index 2094686822..00b0b102ee 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-table/StringDetailsTable.tsx @@ -195,7 +195,7 @@ const StringDetailsTable = (props: Props) => { title={!isValid ? noEditableText : undefined} anchorClassName={styles.tooltipAnchor} className={styles.tooltip} - position="top" + position="left" data-testid="string-value-tooltip" > Date: Thu, 30 Nov 2023 10:37:48 +0200 Subject: [PATCH 93/96] scan in lower batches for tree-view --- .../strategies/standalone.strategy.spec.ts | 34 +++++++++++++++++++ .../scanner/strategies/standalone.strategy.ts | 6 ++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts index 5ccc7785b0..694c79d57c 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts @@ -101,6 +101,40 @@ describe('Standalone Scanner Strategy', () => { null, ); }); + it('should scan 2000 items when count provided more then 2k', async () => { + const args = { ...getKeysDto, count: 10_000, match: '*' }; + jest.spyOn(Utils, 'getTotal').mockResolvedValue(mockGetTotalResponse_1); + + when(browserTool.execCommand) + .calledWith( + mockBrowserClientMetadata, + BrowserToolKeysCommands.Scan, + expect.anything(), + null, + ) + .mockResolvedValue([0, [getKeyInfoResponse.name]]); + + strategy.getKeysInfo = jest.fn().mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockBrowserClientMetadata, args); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total: 1, + scanned: 2000, + keys: [getKeyInfoResponse], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalled(); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 1, + mockBrowserClientMetadata, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', args.match, 'COUNT', 2000], + null, + ); + }); it('should return keys names and type only', async () => { const args = { ...getKeysDto, type: 'string', match: 'pattern*', keysInfo: false, diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts index 79739907a5..ed98f915ac 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts @@ -86,6 +86,8 @@ export class StandaloneStrategy extends AbstractStrategy { count: number, type?: RedisDataType, ): Promise { + const COUNT = Math.min(2000, count); + let fullScanned = false; // todo: remove settings from here. threshold should be part of query? const settings = await this.settingsService.getAppSettings('1'); @@ -97,7 +99,7 @@ export class StandaloneStrategy extends AbstractStrategy { node.scanned < settings.scanThreshold ) ) { - let commandArgs = [`${node.cursor}`, 'MATCH', match, 'COUNT', count]; + let commandArgs = [`${node.cursor}`, 'MATCH', match, 'COUNT', COUNT]; if (type) { commandArgs = [...commandArgs, 'TYPE', type]; } @@ -112,7 +114,7 @@ export class StandaloneStrategy extends AbstractStrategy { // eslint-disable-next-line no-param-reassign node.cursor = parseInt(nextCursor, 10); // eslint-disable-next-line no-param-reassign - node.scanned += count; + node.scanned += COUNT; node.keys.push(...keys); fullScanned = node.cursor === 0; } From a44ad24c917cfa5c052e396f562acebcdd713780 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 30 Nov 2023 15:56:17 +0100 Subject: [PATCH 94/96] add afterEach to return back scan value --- tests/e2e/tests/web/critical-path/browser/scan-keys.e2e.ts | 5 ++++- tests/e2e/tests/web/critical-path/settings/settings.e2e.ts | 5 +++++ tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/e2e/tests/web/critical-path/browser/scan-keys.e2e.ts b/tests/e2e/tests/web/critical-path/browser/scan-keys.e2e.ts index 3d51c36c0c..286b01f8ee 100644 --- a/tests/e2e/tests/web/critical-path/browser/scan-keys.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/scan-keys.e2e.ts @@ -32,7 +32,10 @@ fixture `Browser - Specify Keys to Scan` .beforeEach(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); }) - .afterEach(async() => { + .afterEach(async t => { + await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); + await t.click(settingsPage.accordionAdvancedSettings); + await settingsPage.changeKeysToScanValue('10000'); //Clear and delete database await browserPage.Cli.sendCommandInCli(`DEL ${keys.join(' ')}`); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); diff --git a/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts b/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts index fcc26fdc21..24d32eaa08 100644 --- a/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts +++ b/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts @@ -21,6 +21,11 @@ fixture `Settings` .clientScripts({ content: `(${explicitErrorHandler.toString()})()` }) .beforeEach(async() => { await databaseHelper.acceptLicenseTerms(); + }) + .afterEach(async t => { + await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); + await t.click(settingsPage.accordionAdvancedSettings); + await settingsPage.changeKeysToScanValue('10000'); }); test('Verify that user can customize a number of keys to scan in filters per key name or key type', async t => { // Go to Settings page diff --git a/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts b/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts index 33432f584f..178247227f 100644 --- a/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts @@ -21,6 +21,11 @@ fixture `Browser - Specify Keys to Scan` .clientScripts({ content: `(${explicitErrorHandler.toString()})()` }) .beforeEach(async() => { await databaseHelper.acceptLicenseTerms(); + }) + .afterEach(async t => { + await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); + await t.click(settingsPage.accordionAdvancedSettings); + await settingsPage.changeKeysToScanValue('10000'); }); test('Verify that the user not enter the value less than 500 - the system automatically applies min value if user enters less than min', async t => { // Go to Settings page From 2359c2e1bdc42f0c5dd8c412f02d05d158ca46de Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 30 Nov 2023 16:07:40 +0100 Subject: [PATCH 95/96] fix --- tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts b/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts index 178247227f..4dea83f750 100644 --- a/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/scan-keys.e2e.ts @@ -22,9 +22,7 @@ fixture `Browser - Specify Keys to Scan` .beforeEach(async() => { await databaseHelper.acceptLicenseTerms(); }) - .afterEach(async t => { - await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); - await t.click(settingsPage.accordionAdvancedSettings); + .afterEach(async() => { await settingsPage.changeKeysToScanValue('10000'); }); test('Verify that the user not enter the value less than 500 - the system automatically applies min value if user enters less than min', async t => { From b57cafd13f514345cb158a2581d317dde44d4a72 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 30 Nov 2023 16:11:19 +0100 Subject: [PATCH 96/96] fix2 --- tests/e2e/tests/web/critical-path/settings/settings.e2e.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts b/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts index 24d32eaa08..f69c7fbcc0 100644 --- a/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts +++ b/tests/e2e/tests/web/critical-path/settings/settings.e2e.ts @@ -22,9 +22,7 @@ fixture `Settings` .beforeEach(async() => { await databaseHelper.acceptLicenseTerms(); }) - .afterEach(async t => { - await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); - await t.click(settingsPage.accordionAdvancedSettings); + .afterEach(async() => { await settingsPage.changeKeysToScanValue('10000'); }); test('Verify that user can customize a number of keys to scan in filters per key name or key type', async t => {