diff --git a/redisinsight/ui/src/components/production-write-confirmation/ProductionWriteConfirmationProvider.constants.ts b/redisinsight/ui/src/components/production-write-confirmation/ProductionWriteConfirmationProvider.constants.ts index a4ac657918..df545d07a2 100644 --- a/redisinsight/ui/src/components/production-write-confirmation/ProductionWriteConfirmationProvider.constants.ts +++ b/redisinsight/ui/src/components/production-write-confirmation/ProductionWriteConfirmationProvider.constants.ts @@ -20,6 +20,7 @@ export enum BrowserConfirmationCommandId { AddListElements = 'browser:add-list-elements', AddSetMembers = 'browser:add-set-members', AddHashFields = 'browser:add-hash-fields', + AddArrayElements = 'browser:add-array-elements', AddStreamEntry = 'browser:add-stream-entry', RenameKey = 'browser:rename-key', ChangeTtl = 'browser:change-ttl', diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 9190d11dce..6a7a6c4390 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -83,6 +83,8 @@ enum ApiEndpoints { ARRAY_GET_COUNT = 'array/get-count', ARRAY_AGGREGATE = 'array/aggregate', ARRAY_SEARCH = 'array/search', + ARRAY_SET_ELEMENT = 'array/set-element', + ARRAY_APPEND = 'array/append', STREAMS = 'streams', STREAMS_ENTRIES = 'streams/entries', diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/ArrayDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/ArrayDetails.tsx index d344a089cc..a6729ae701 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/ArrayDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/ArrayDetails.tsx @@ -15,10 +15,12 @@ import * as S from './ArrayDetails.styles' export interface Props extends KeyDetailsHeaderProps { keyProp: RedisResponseBuffer | null + onOpenAddItemPanel?: () => void + onCloseAddItemPanel?: () => void } const ArrayDetails = (props: Props) => { - const { keyProp } = props + const { keyProp, onOpenAddItemPanel, onCloseAddItemPanel } = props const [activeTab, setActiveTab] = useState( DEFAULT_ARRAY_DETAILS_TAB, @@ -31,7 +33,11 @@ const ArrayDetails = (props: Props) => { - + diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.constants.ts new file mode 100644 index 0000000000..f1ff16c042 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.constants.ts @@ -0,0 +1,17 @@ +export const ARRAY_ADD_FORM_TEST_ID = 'array-add-form' + +export const VALUE_LABEL = 'Value' +export const INDEX_LABEL = 'Index' +export const INDEX_PLACEHOLDER = 'Leave empty to append to the end' +export const INDEX_HINT = + 'Leave empty to append the value to the end of the array. Enter an index to ' + + 'set the value at that exact position (overwriting any existing value there).' +export const INVALID_INDEX_MESSAGE = + 'Index must be an integer string between 0 and 18446744073709551614' +export const ADD_BUTTON_LABEL = 'Add' +export const CANCEL_BUTTON_LABEL = 'Cancel' + +export const CONFIRM_TITLE = 'Add element to a production database?' +export const CONFIRM_DESCRIPTION = + 'You are about to add an element to a key on a production database.' +export const CONFIRM_BUTTON_TEXT = 'Add' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.spec.tsx new file mode 100644 index 0000000000..af2b8c27b8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.spec.tsx @@ -0,0 +1,130 @@ +import React from 'react' +import { + act, + fireEvent, + initialStateDefault, + mockStore, + render, + screen, + waitFor, +} from 'uiSrc/utils/test-utils' +import { apiService } from 'uiSrc/services' +import { stringToBuffer } from 'uiSrc/utils' + +import { ArrayAddForm } from './ArrayAddForm' + +const keyProp = stringToBuffer('mykey') + +// The form's key must be the live selected key, otherwise applyArrayWriteResult +// suppresses the success side effects (onSuccess/closePanel) as a stale write. +const stateWithKeySelected = { + ...initialStateDefault, + app: { + ...initialStateDefault.app, + context: { + ...initialStateDefault.app.context, + browser: { + ...initialStateDefault.app.context.browser, + keyList: { + ...initialStateDefault.app.context.browser.keyList, + selectedKey: keyProp, + }, + }, + }, + }, +} + +const renderForm = (closePanel = jest.fn()) => + render(, { + store: mockStore(stateWithKeySelected), + }) + +const findCall = (fragment: string) => + (apiService.post as jest.Mock).mock.calls.find(([url]) => + (url as string).includes(fragment), + ) + +describe('ArrayAddForm', () => { + beforeEach(() => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: {} }) + }) + + it('renders the value and index inputs', () => { + renderForm() + expect(screen.getByTestId('array-add-form-value')).toBeInTheDocument() + expect(screen.getByTestId('array-add-form-index')).toBeInTheDocument() + }) + + it('appends (POST /array/append) when the index is left empty', async () => { + const closePanel = jest.fn() + renderForm(closePanel) + + fireEvent.change(screen.getByTestId('array-add-form-value'), { + target: { value: 'hello' }, + }) + fireEvent.click(screen.getByTestId('array-add-form-submit')) + + await waitFor(() => { + expect(findCall('array/append')).toBeTruthy() + }) + expect(findCall('array/set-element')).toBeFalsy() + expect(closePanel).toHaveBeenCalled() + }) + + it('does not close the panel if the form unmounted before the write resolved', async () => { + // Hold the write open so we can unmount (key switch + fresh panel) first. + let resolvePost: (value: unknown) => void = () => {} + apiService.post = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolvePost = resolve + }), + ) + const closePanel = jest.fn() + const { unmount } = renderForm(closePanel) + + fireEvent.change(screen.getByTestId('array-add-form-value'), { + target: { value: 'hello' }, + }) + fireEvent.click(screen.getByTestId('array-add-form-submit')) + + unmount() + await act(async () => { + resolvePost({ status: 200, data: {} }) + }) + + // The stale success callback must not close the now-current panel. + expect(closePanel).not.toHaveBeenCalled() + }) + + it('sets at index (POST /array/set-element) when an index is provided', async () => { + renderForm() + + fireEvent.change(screen.getByTestId('array-add-form-value'), { + target: { value: 'hello' }, + }) + fireEvent.change(screen.getByTestId('array-add-form-index'), { + target: { value: '5' }, + }) + fireEvent.click(screen.getByTestId('array-add-form-submit')) + + await waitFor(() => { + const call = findCall('array/set-element') + expect(call).toBeTruthy() + expect((call?.[1] as { index: string }).index).toBe('5') + }) + expect(findCall('array/append')).toBeFalsy() + }) + + it('disables Add for a non-canonical index', () => { + renderForm() + + act(() => { + fireEvent.change(screen.getByTestId('array-add-form-index'), { + target: { value: '007' }, + }) + }) + + expect(screen.getByTestId('array-add-form-submit')).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.tsx new file mode 100644 index 0000000000..2c63eeafdd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useRef, useState } from 'react' + +import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' +import { selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { appendArrayElement, addArrayElement } from 'uiSrc/slices/browser/array' +import { + BrowserConfirmationCommandId, + useProductionWriteConfirmation, +} from 'uiSrc/components/production-write-confirmation' +import { stringToSerializedBufferFormat } from 'uiSrc/utils' +import { parseArrayIndex } from 'uiSrc/utils/arrayIndex' +import { FormField } from 'uiSrc/components/base/forms/FormField' +import { TextInput } from 'uiSrc/components/base/inputs' +import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' +import { + PrimaryButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' + +import { EntryContent } from '../../common/AddKeysContainer.styled' +import { + ARRAY_ADD_FORM_TEST_ID as TEST_ID, + ADD_BUTTON_LABEL, + CANCEL_BUTTON_LABEL, + CONFIRM_BUTTON_TEXT, + CONFIRM_DESCRIPTION, + CONFIRM_TITLE, + INDEX_HINT, + INDEX_LABEL, + INDEX_PLACEHOLDER, + INVALID_INDEX_MESSAGE, + VALUE_LABEL, +} from './ArrayAddForm.constants' +import { ArrayAddFormProps } from './ArrayAddForm.types' + +/** + * Content of the "Add element" slide-out panel (rendered inside the shared + * `AddKeysContainer`, matching List / Vector Set). The index is optional: + * leaving it empty appends to the end (POST /array/append, ARSET at the current + * length); providing one sets at that index (POST /array/set-element). + * `ARINSERT` is intentionally not used — see docs/array-modify-vertical-plan.md. + */ +export const ArrayAddForm = ({ keyProp, closePanel }: ArrayAddFormProps) => { + const dispatch = useAppDispatch() + const { viewFormat } = useAppSelector(selectedKeySelector) + const { requestConfirmation } = useProductionWriteConfirmation() + + const [value, setValue] = useState('') + const [index, setIndex] = useState('') + + // A write resolves asynchronously and the slice still fires onSuccess while + // its target key is selected. But the user may have closed this panel (key + // switch, then reopened a fresh panel) before it resolved — this instance is + // unmounted, and running closePanel would discard the *current* panel. Ignore + // the callback once this form is gone. + const isMounted = useRef(true) + useEffect( + () => () => { + isMounted.current = false + }, + [], + ) + + // The index input is optional (empty → append). When provided it must be a + // canonical decimal string, matching the backend @IsArrayIndex validator. + const trimmedIndex = index.trim() + const indexInvalid = + trimmedIndex.length > 0 && parseArrayIndex(trimmedIndex) !== trimmedIndex + + const handleSuccess = () => { + if (!isMounted.current) { + return + } + setValue('') + setIndex('') + closePanel() + } + + const handleAdd = () => { + requestConfirmation({ + title: CONFIRM_TITLE, + actionDescription: CONFIRM_DESCRIPTION, + confirmButtonText: CONFIRM_BUTTON_TEXT, + commandId: BrowserConfirmationCommandId.AddArrayElements, + disableConfirmationInput: true, + onConfirm: () => { + const serialized = stringToSerializedBufferFormat(viewFormat, value) + if (trimmedIndex.length === 0) { + dispatch( + appendArrayElement( + { key: keyProp, value: serialized }, + handleSuccess, + ), + ) + } else { + dispatch( + addArrayElement( + { key: keyProp, index: trimmedIndex, value: serialized }, + handleSuccess, + ), + ) + } + }, + }) + } + + return ( + + + + + + + + + + + + + + + + + + + closePanel(true)} + data-testid={`${TEST_ID}-cancel`} + > + {CANCEL_BUTTON_LABEL} + + + + + {ADD_BUTTON_LABEL} + + + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.types.ts new file mode 100644 index 0000000000..fd9d9a015f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.types.ts @@ -0,0 +1,9 @@ +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' + +export interface ArrayAddFormProps { + /** The array key being viewed; the element is added to it. */ + keyProp: RedisResponseBuffer + /** Closes the add panel. `isCancelled` distinguishes an explicit Cancel from + * a close-after-success (mirrors the other types' add panels). */ + closePanel: (isCancelled?: boolean) => void +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/index.ts new file mode 100644 index 0000000000..71e4773e5a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/index.ts @@ -0,0 +1 @@ +export { ArrayAddForm } from './ArrayAddForm' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx new file mode 100644 index 0000000000..67a4311e90 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { stringToBuffer } from 'uiSrc/utils' + +import ViewTab from './ViewTab' +import { useArrayRangeQuery } from '../hooks' + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useArrayRangeQuery: jest.fn(), +})) + +const keyA = stringToBuffer('key-a') +const keyB = stringToBuffer('key-b') + +const ADD_BTN = 'add-key-value-items-btn' +const PANEL = 'array-add-form' + +const mockRangeQuery = (overrides = {}) => + (useArrayRangeQuery as jest.Mock).mockReturnValue({ + start: '0', + end: '9', + showEmpty: true, + setStart: jest.fn(), + setEnd: jest.fn(), + setShowEmpty: jest.fn(), + runQuery: jest.fn(), + resetQuery: jest.fn(), + isArrayKeyReady: true, + elements: [], + loading: false, + error: '', + ...overrides, + }) + +describe('ViewTab', () => { + beforeEach(() => { + mockRangeQuery() + }) + + it('opens the add panel and fires open telemetry', () => { + const onOpenAddItemPanel = jest.fn() + render() + + expect(screen.queryByTestId(PANEL)).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId(ADD_BTN)) + + expect(onOpenAddItemPanel).toHaveBeenCalled() + expect(screen.getByTestId(PANEL)).toBeInTheDocument() + }) + + it('fires cancel telemetry and hides the panel on Cancel', () => { + const onCloseAddItemPanel = jest.fn() + render() + + fireEvent.click(screen.getByTestId(ADD_BTN)) + fireEvent.click(screen.getByTestId(`${PANEL}-cancel`)) + + expect(onCloseAddItemPanel).toHaveBeenCalled() + expect(screen.queryByTestId(PANEL)).not.toBeInTheDocument() + }) + + it('closes the panel when the selected key changes', () => { + const { rerender } = render() + + fireEvent.click(screen.getByTestId(ADD_BTN)) + expect(screen.getByTestId(PANEL)).toBeInTheDocument() + + rerender() + expect(screen.queryByTestId(PANEL)).not.toBeInTheDocument() + }) + + it('keeps the panel open when keyProp is a new buffer with the same bytes', () => { + const { rerender } = render() + + fireEvent.click(screen.getByTestId(ADD_BTN)) + expect(screen.getByTestId(PANEL)).toBeInTheDocument() + + // Same key, fresh buffer object — a byte-exact compare must not close it. + rerender() + expect(screen.getByTestId(PANEL)).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts new file mode 100644 index 0000000000..7da8416730 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components' +import { FlexItem } from 'uiSrc/components/base/layout/flex' + +/** Subheader strip hosting the right-aligned "Add Elements" action, mirroring + * VectorSetKeySubheader so the array view matches the other key types. */ +export const SubheaderContainer = styled(FlexItem)` + padding: ${({ theme }) => + `${theme.core?.space.space150} ${theme.core?.space.space200} 0`}; +` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx index b3d7b0fac5..0cd76a87ba 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx @@ -1,18 +1,55 @@ -import React from 'react' +import React, { useEffect, useRef, useState } from 'react' +import AutoSizer from 'react-virtualized-auto-sizer' import { useAppSelector } from 'uiSrc/slices/hooks' import { selectedKeySelector } from 'uiSrc/slices/browser/keys' -import { bufferToString } from 'uiSrc/utils' +import { bufferToString, isEqualBuffers } from 'uiSrc/utils' +import { Row } from 'uiSrc/components/base/layout/flex' +import { AddItemsAction } from 'uiSrc/pages/browser/modules/key-details/components/key-details-actions' import { ArrayDetailsTable } from '../array-details-table' import { ArrayRangeForm } from '../array-range-form' +import { ArrayAddForm } from '../array-add-form' +import { AddKeysContainer } from '../../common/AddKeysContainer.styled' import { useArrayRangeQuery } from '../hooks' import * as S from '../tabs.styles' +import * as LS from './ViewTab.styles' import { ViewTabProps } from './ViewTab.types' -const ViewTab = ({ keyProp }: ViewTabProps) => { +const ADD_ELEMENTS_TITLE = 'Add Elements' + +const ViewTab = ({ + keyProp, + onOpenAddItemPanel, + onCloseAddItemPanel, +}: ViewTabProps) => { const { loading } = useAppSelector(selectedKeySelector) const keyName = keyProp ? bufferToString(keyProp) : '' + const [isAddPanelOpen, setIsAddPanelOpen] = useState(false) + const prevKeyProp = useRef(keyProp) + + // Close the panel when the selected key changes, otherwise a confirmed write + // from a panel left open could target the newly selected key. Compare bytes + // (not the decoded name) — distinct binary keys can decode to the same + // Unicode string and would otherwise leave a stale panel open. + useEffect(() => { + if (!isEqualBuffers(prevKeyProp.current, keyProp)) { + setIsAddPanelOpen(false) + } + prevKeyProp.current = keyProp + }, [keyProp]) + + const openAddPanel = () => { + setIsAddPanelOpen(true) + onOpenAddItemPanel?.() + } + + const closeAddPanel = (isCancelled?: boolean) => { + setIsAddPanelOpen(false) + if (isCancelled) { + onCloseAddItemPanel?.() + } + } const { start, @@ -44,6 +81,21 @@ const ViewTab = ({ keyProp }: ViewTabProps) => { onReset={resetQuery} disabled={!isArrayKeyReady} /> + {isArrayKeyReady && ( + + + {({ width = 0 }) => ( + + + + )} + + + )} {!loading && ( @@ -54,6 +106,11 @@ const ViewTab = ({ keyProp }: ViewTabProps) => { /> )} + {isAddPanelOpen && keyProp && ( + + + + )} ) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.types.ts index 8fc47268fe..bdc4e71b45 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.types.ts @@ -2,4 +2,8 @@ import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' export interface ViewTabProps { keyProp: RedisResponseBuffer | null + /** Telemetry hooks supplied by KeyDetails — fired when the add panel opens + * and when it is dismissed via Cancel, matching the other key types. */ + onOpenAddItemPanel?: () => void + onCloseAddItemPanel?: () => void } diff --git a/redisinsight/ui/src/slices/browser/array.ts b/redisinsight/ui/src/slices/browser/array.ts index d9fc585b1f..3ee53d2ba3 100644 --- a/redisinsight/ui/src/slices/browser/array.ts +++ b/redisinsight/ui/src/slices/browser/array.ts @@ -15,6 +15,7 @@ import { DEFAULT_ERROR_MESSAGE, getApiErrorMessage, getUrl, + isEqualBuffers, isStatusSuccessful, Maybe, } from 'uiSrc/utils' @@ -29,9 +30,12 @@ import { FetchArrayRangeParams, FetchArrayScanParams, SearchArrayParams, + AppendArrayElementParams, + AddArrayElementParams, } from 'uiSrc/slices/interfaces/array' -import { RedisString } from 'uiSrc/slices/interfaces/app' -import { updateSelectedKeyRefreshTime } from './keys' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces/app' +import { appContextSelectedKey } from 'uiSrc/slices/app/context' +import { refreshKeyInfoAction, updateSelectedKeyRefreshTime } from './keys' import { AppDispatch, RootState } from '../store' import { addErrorNotification } from '../app/notifications' @@ -611,3 +615,89 @@ export function refreshArray(key: RedisString) { } } } + +/** + * Apply a successful write's side effects — close/clear the panel + * (`onSuccessAction`) and replay the read surface + key header — but only if + * `key` is still the selected key. A write can resolve after the user moved to + * another key: `onSuccessAction` would close/clear the now-different panel, and + * refreshArray writes into the shared array slice (its range controller would + * abort the newly-selected key's load) while the header reads Length/Count/Size + * from the keys slice. + * + * The live selection is read from the browser context, not + * `keys.selectedKey.data` — fetchKeyInfo keeps the previous `data` in place + * while the newly clicked key loads, so that field is stale during the switch. + * Comparison is byte-exact (isEqualBuffers), since two distinct binary names + * can decode to the same Unicode string. + */ +function applyArrayWriteResult( + key: RedisResponseBuffer, + onSuccessAction?: () => void, +) { + return (dispatch: AppDispatch, stateInit: () => RootState) => { + const selectedKey = appContextSelectedKey(stateInit()) + if (!selectedKey || !isEqualBuffers(selectedKey, key)) { + return + } + onSuccessAction?.() + dispatch(refreshArray(key)) + dispatch(refreshKeyInfoAction(key)) + } +} + +/** + * Append a value to the end of the array (POST /array/append → ARSET at the + * current length). On success the displayed surface, counters, and key header + * are refreshed so the new element appears. + */ +export function appendArrayElement( + params: AppendArrayElementParams, + onSuccessAction?: () => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + try { + const state = stateInit() + const { status } = await apiService.post( + arrayUrl(state, ApiEndpoints.ARRAY_APPEND), + { keyName: params.key, value: params.value }, + encodingParams(state), + ) + if (isStatusSuccessful(status)) { + dispatch(applyArrayWriteResult(params.key, onSuccessAction)) + } + } catch (error) { + dispatch(addErrorNotification(error as IAddInstanceErrorPayload)) + onFailAction?.() + } + } +} + +/** + * Add a value at an explicit index (POST /array/set-element → ARSET key index + * value). Used by the "Add element" form when the user points at an index. + * Refreshes the displayed surface, counters, and key header on success. + */ +export function addArrayElement( + params: AddArrayElementParams, + onSuccessAction?: () => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + try { + const state = stateInit() + const { status } = await apiService.post( + arrayUrl(state, ApiEndpoints.ARRAY_SET_ELEMENT), + { keyName: params.key, index: params.index, value: params.value }, + encodingParams(state), + ) + if (isStatusSuccessful(status)) { + dispatch(applyArrayWriteResult(params.key, onSuccessAction)) + } + } catch (error) { + dispatch(addErrorNotification(error as IAddInstanceErrorPayload)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/array.ts b/redisinsight/ui/src/slices/interfaces/array.ts index 305d391cea..db436dbe19 100644 --- a/redisinsight/ui/src/slices/interfaces/array.ts +++ b/redisinsight/ui/src/slices/interfaces/array.ts @@ -7,7 +7,7 @@ import { GetArraySearchResponse, GetArrayScanResponse, } from 'apiClient' -import { RedisString } from 'uiSrc/slices/interfaces/app' +import { RedisResponseBuffer, RedisString } from 'uiSrc/slices/interfaces/app' /** * Mirror of the backend `ArrayAggregateOperation` enum (BE @@ -158,6 +158,22 @@ export interface FetchArrayRangeParams { resetData?: boolean } +/** Append a value to the end of the array (ARSET at the current length, + * computed server-side). The key is always a buffer (the selected key), so + * the stale-key guard can compare it byte-exactly with isEqualBuffers. */ +export interface AppendArrayElementParams { + key: RedisResponseBuffer + value: RedisString +} + +/** Add a value at an explicit index (ARSET key index value). Index is a + * numeric string per the unsigned-64-bit contract. */ +export interface AddArrayElementParams { + key: RedisResponseBuffer + index: string + value: RedisString +} + export interface FetchArrayScanParams { key: RedisString start: string diff --git a/redisinsight/ui/src/slices/tests/browser/array.spec.ts b/redisinsight/ui/src/slices/tests/browser/array.spec.ts index 5364c626dc..a7475f74e4 100644 --- a/redisinsight/ui/src/slices/tests/browser/array.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/array.spec.ts @@ -1,7 +1,7 @@ import axios from 'axios' import { cloneDeep } from 'lodash' import { apiService } from 'uiSrc/services' -import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/utils' +import { DEFAULT_ERROR_MESSAGE, stringToBuffer } from 'uiSrc/utils' import { IAddInstanceErrorPayload } from 'uiSrc/slices/app/notifications' import { cleanup, @@ -35,6 +35,8 @@ import reducer, { fetchArrayLength, fetchArrayCount, refreshArray, + appendArrayElement, + addArrayElement, searchArray, } from '../../browser/array' import { arrayGrepPredicateFactory } from 'uiSrc/mocks/factories/browser/array/arrayGrepPredicate.factory' @@ -53,6 +55,29 @@ jest.mock('uiSrc/services', () => ({ // friction with the generated DTO types (which model the Buffer branch as // `{ type: 'Buffer'; data: number[] }`). const mockKey = 'readings' +// Buffer form for the write thunks: the selected-key guard compares buffers +// (isEqualBuffers), exactly as the UI passes them in production. +const mockKeyBuffer = stringToBuffer(mockKey) + +// A store whose live (browser-context) selected key is `name` — array writes +// only apply their success side effects while that key is still selected. +const storeWithSelectedKey = (name = mockKeyBuffer) => + mockStore({ + ...initialStateDefault, + app: { + ...initialStateDefault.app, + context: { + ...initialStateDefault.app.context, + browser: { + ...initialStateDefault.app.context.browser, + keyList: { + ...initialStateDefault.app.context.browser.keyList, + selectedKey: name, + }, + }, + }, + }, + }) let store: typeof mockedStore let dateNow: jest.SpyInstance @@ -704,6 +729,143 @@ describe('array slice', () => { }) }) + describe('appendArrayElement', () => { + it('posts keyName/value to array/append, calls onSuccess, and refreshes', async () => { + apiService.post = jest + .fn() + .mockResolvedValue({ status: 200, data: { keyName: mockKey } }) + const onSuccess = jest.fn() + const local = storeWithSelectedKey() + + await local.dispatch( + appendArrayElement({ key: mockKeyBuffer, value: 'v' }, onSuccess), + ) + + const appendCall = (apiService.post as jest.Mock).mock.calls.find( + ([url]) => url.includes('array/append'), + ) + expect(appendCall).toBeTruthy() + expect(appendCall[1]).toEqual({ keyName: mockKeyBuffer, value: 'v' }) + expect(onSuccess).toHaveBeenCalled() + // refreshArray re-reads length/count after the add. + const lengthCall = (apiService.post as jest.Mock).mock.calls.find( + ([url]) => url.includes('array/get-length'), + ) + expect(lengthCall).toBeTruthy() + // refreshKeyInfoAction re-reads the key header (Length/Count/Size). + const keyInfoCall = (apiService.post as jest.Mock).mock.calls.find( + ([url]) => url.includes('keys/get-info'), + ) + expect(keyInfoCall).toBeTruthy() + }) + + it('skips the success side effects when the user has switched to another key', async () => { + apiService.post = jest + .fn() + .mockResolvedValue({ status: 200, data: { keyName: mockKey } }) + const onSuccess = jest.fn() + // Live selection moved on before the append resolved. + const local = storeWithSelectedKey(stringToBuffer('another-key')) + + await local.dispatch( + appendArrayElement({ key: mockKeyBuffer, value: 'v' }, onSuccess), + ) + + // The write still happens… + expect( + (apiService.post as jest.Mock).mock.calls.find(([url]) => + url.includes('array/append'), + ), + ).toBeTruthy() + // …but onSuccess (which closes/clears the now-different panel) and the + // stale refresh that would clobber the new key's view do not run. + expect(onSuccess).not.toHaveBeenCalled() + expect( + (apiService.post as jest.Mock).mock.calls.find(([url]) => + url.includes('array/get-length'), + ), + ).toBeUndefined() + }) + + it('notifies and calls onFail on error', async () => { + const rejected = { + response: { status: 500, data: { message: 'boom' } }, + } + apiService.post = jest.fn().mockRejectedValue(rejected) + const onFail = jest.fn() + + await store.dispatch( + appendArrayElement( + { key: mockKeyBuffer, value: 'v' }, + undefined, + onFail, + ), + ) + + expect(onFail).toHaveBeenCalled() + expect(store.getActions()).toContainEqual( + addErrorNotification(rejected as IAddInstanceErrorPayload), + ) + }) + }) + + describe('addArrayElement (set at index)', () => { + it('posts keyName/index/value to array/set-element, calls onSuccess, and refreshes', async () => { + apiService.post = jest + .fn() + .mockResolvedValue({ status: 200, data: { keyName: mockKey } }) + const onSuccess = jest.fn() + const local = storeWithSelectedKey() + + await local.dispatch( + addArrayElement( + { key: mockKeyBuffer, index: '5', value: 'v' }, + onSuccess, + ), + ) + + const setCall = (apiService.post as jest.Mock).mock.calls.find( + ([url]) => url.includes('array/set-element'), + ) + expect(setCall).toBeTruthy() + expect(setCall[1]).toEqual({ + keyName: mockKeyBuffer, + index: '5', + value: 'v', + }) + expect(onSuccess).toHaveBeenCalled() + const lengthCall = (apiService.post as jest.Mock).mock.calls.find( + ([url]) => url.includes('array/get-length'), + ) + expect(lengthCall).toBeTruthy() + const keyInfoCall = (apiService.post as jest.Mock).mock.calls.find( + ([url]) => url.includes('keys/get-info'), + ) + expect(keyInfoCall).toBeTruthy() + }) + + it('notifies and calls onFail on error', async () => { + const rejected = { + response: { status: 500, data: { message: 'boom' } }, + } + apiService.post = jest.fn().mockRejectedValue(rejected) + const onFail = jest.fn() + + await store.dispatch( + addArrayElement( + { key: mockKeyBuffer, index: '5', value: 'v' }, + undefined, + onFail, + ), + ) + + expect(onFail).toHaveBeenCalled() + expect(store.getActions()).toContainEqual( + addErrorNotification(rejected as IAddInstanceErrorPayload), + ) + }) + }) + describe('searchArray', () => { const predicates = arrayGrepPredicateFactory.buildList(1)