From 4ea0c84c0c2a4c214d97685d75134a6b88aa2351 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 26 Jun 2026 17:49:56 +0300 Subject: [PATCH 1/3] feat(ui): add "Add element" to the array View tab (append or set-at-index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an inline "Add element" form in the View tab: a value input and an optional index. Empty index appends to the end (POST /array/append, the atomic ARSET-at-length); an explicit index sets there (POST /array/set-element). The add is wrapped in the production-write confirmation, the index is validated as a canonical decimal, and the View (range + length/count) is refreshed on success so the new element appears. ARINSERT is intentionally not used — see docs/array-modify-vertical-plan.md. References: #RI-8222 Co-Authored-By: Claude Opus 4.8 --- ...tionWriteConfirmationProvider.constants.ts | 1 + redisinsight/ui/src/constants/api.ts | 2 + .../components/array-details/ArrayDetails.tsx | 10 +- .../array-add-form/ArrayAddForm.constants.ts | 17 ++ .../array-add-form/ArrayAddForm.spec.tsx | 75 ++++++++ .../array-add-form/ArrayAddForm.tsx | 143 +++++++++++++++ .../array-add-form/ArrayAddForm.types.ts | 9 + .../array-details/array-add-form/index.ts | 1 + .../array-details/view-tab/ViewTab.spec.tsx | 83 +++++++++ .../array-details/view-tab/ViewTab.styles.ts | 9 + .../array-details/view-tab/ViewTab.tsx | 63 ++++++- .../array-details/view-tab/ViewTab.types.ts | 4 + redisinsight/ui/src/slices/browser/array.ts | 94 +++++++++- .../ui/src/slices/interfaces/array.ts | 18 +- .../ui/src/slices/tests/browser/array.spec.ts | 164 +++++++++++++++++- 15 files changed, 684 insertions(+), 9 deletions(-) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.constants.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.types.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts 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..6ab6bca910 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.spec.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { act, fireEvent, 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') + +const renderForm = (closePanel = jest.fn()) => + render() + +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('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..da4266f7cb --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-add-form/ArrayAddForm.tsx @@ -0,0 +1,143 @@ +import React, { 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, atomic 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('') + + // 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 = () => { + 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) From fd02e63609c27f466af557c39799c03bcff836ec Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 30 Jun 2026 10:30:30 +0300 Subject: [PATCH 2/3] test(ui): seed selected key in ArrayAddForm spec applyArrayWriteResult now gates the write's success side effects (onSuccess/closePanel) on the live browser-context selected key matching keyProp. The ArrayAddForm spec rendered with the default store, where app.context.browser.keyList.selectedKey is null, so closePanel was never invoked and the append test's assertion no longer held. Render the form with a store whose selected key is the form's keyProp so the success path runs, exercising the panel-close behaviour as it does in the app. References: #RI-8222 Co-Authored-By: Claude Opus 4.8 --- .../array-add-form/ArrayAddForm.spec.tsx | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 index 6ab6bca910..95e6c5d650 100644 --- 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 @@ -1,5 +1,13 @@ import React from 'react' -import { act, fireEvent, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import { + act, + fireEvent, + initialStateDefault, + mockStore, + render, + screen, + waitFor, +} from 'uiSrc/utils/test-utils' import { apiService } from 'uiSrc/services' import { stringToBuffer } from 'uiSrc/utils' @@ -7,8 +15,29 @@ 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() + render(, { + store: mockStore(stateWithKeySelected), + }) const findCall = (fragment: string) => (apiService.post as jest.Mock).mock.calls.find(([url]) => From 01b511bb95ec517fd74a95efb0509e377ca6c56e Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 30 Jun 2026 12:18:44 +0300 Subject: [PATCH 3/3] fix(ui): ignore stale add-panel success callback after unmount The selected-key guard in applyArrayWriteResult is not enough: a user can submit a write for key A, switch away, then return to A and open a fresh add panel before the first request resolves. The live key is A again, so the guard passes and runs the original form's handleSuccess, whose closePanel closes the *current* (different) panel. Guard handleSuccess with an isMounted ref so a form instance's success callback no-ops once that form has unmounted; the refresh still runs (A is selected, the write landed there). Adds a regression test that unmounts mid-flight and asserts closePanel is not called. References: #RI-8222 Co-Authored-By: Claude Opus 4.8 --- .../array-add-form/ArrayAddForm.spec.tsx | 26 +++++++++++++++++++ .../array-add-form/ArrayAddForm.tsx | 22 +++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) 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 index 95e6c5d650..af2b8c27b8 100644 --- 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 @@ -71,6 +71,32 @@ describe('ArrayAddForm', () => { 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() 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 index da4266f7cb..2c63eeafdd 100644 --- 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 @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { selectedKeySelector } from 'uiSrc/slices/browser/keys' @@ -36,8 +36,8 @@ 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, atomic ARSET at the - * current length); providing one sets at that index (POST /array/set-element). + * 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) => { @@ -48,6 +48,19 @@ export const ArrayAddForm = ({ keyProp, closePanel }: ArrayAddFormProps) => { 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() @@ -55,6 +68,9 @@ export const ArrayAddForm = ({ keyProp, closePanel }: ArrayAddFormProps) => { trimmedIndex.length > 0 && parseArrayIndex(trimmedIndex) !== trimmedIndex const handleSuccess = () => { + if (!isMounted.current) { + return + } setValue('') setIndex('') closePanel()